From 5c3c2961fcae0ea1169c1b13e653b8c7617faf48 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 18 Dec 2025 12:11:42 +0800 Subject: [PATCH 01/18] Initial MediaPickerFolder and MediaPickerItem compose --- .../securesms/mediasend/MediaFolder.java | 42 --- .../securesms/mediasend/MediaFolder.kt | 18 + .../securesms/mediasend/MediaSendViewModel.kt | 42 ++- .../securesms/mediasend/compose/Components.kt | 347 ++++++++++++++++++ .../compose/MediaPickerFolderScreen.kt | 119 ++++++ .../compose/MediaPickerItemScreen.kt | 211 +++++++++++ 6 files changed, 721 insertions(+), 58 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.java deleted file mode 100644 index b84ebfd276..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.net.Uri; -import androidx.annotation.NonNull; - -/** - * Represents a folder that's shown in {@link MediaPickerFolderFragment}. - */ -public class MediaFolder { - - private final Uri thumbnailUri; - private final String title; - private final int itemCount; - private final String bucketId; - - MediaFolder(@NonNull Uri thumbnailUri, @NonNull String title, int itemCount, @NonNull String bucketId) { - this.thumbnailUri = thumbnailUri; - this.title = title; - this.itemCount = itemCount; - this.bucketId = bucketId; - } - - Uri getThumbnailUri() { - return thumbnailUri; - } - - public String getTitle() { - return title; - } - - int getItemCount() { - return itemCount; - } - - public String getBucketId() { - return bucketId; - } - - enum FolderType { - NORMAL, CAMERA - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.kt new file mode 100644 index 0000000000..038af1d22e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.kt @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.mediasend + +import android.net.Uri + + +/** + * Represents a folder that's shown in MediaPickerFolderFragment. + */ +data class MediaFolder( + val thumbnailUri: Uri?, + val title: String, + val itemCount: Int, + val bucketId: String, +) { + enum class FolderType { + NORMAL, CAMERA + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt index e1d1557b71..247d3f5921 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt @@ -32,7 +32,7 @@ import javax.inject.Inject * Manages the observable datasets available in [MediaSendActivity]. */ @HiltViewModel -internal class MediaSendViewModel @Inject constructor( +class MediaSendViewModel @Inject constructor( private val application: Application, proStatusManager: ProStatusManager, recipientRepository: RecipientRepository, @@ -95,14 +95,18 @@ internal class MediaSendViewModel @Inject constructor( repository.getPopulatedMedia(context, newMedia) { populatedMedia: List -> runOnMain { // Use the new filter function that returns valid items AND errors - var (filteredMedia, errors) = getFilteredMedia(context, populatedMedia, mediaConstraints) + var (filteredMedia, errors) = getFilteredMedia( + context, + populatedMedia, + mediaConstraints + ) // Report errors if they occurred if (errors.contains(Error.ITEM_TOO_LARGE)) { _effects.tryEmit(MediaSendEffect.ShowError(Error.ITEM_TOO_LARGE)) } else if (errors.contains(Error.INVALID_TYPE_ONLY)) { _effects.tryEmit(MediaSendEffect.ShowError(Error.INVALID_TYPE_ONLY)) - }else if (errors.contains(Error.MIXED_TYPE)) { + } else if (errors.contains(Error.MIXED_TYPE)) { _effects.tryEmit(MediaSendEffect.ShowError(Error.MIXED_TYPE)) } @@ -146,14 +150,18 @@ internal class MediaSendViewModel @Inject constructor( fun onSingleMediaSelected(context: Context, media: Media) { repository.getPopulatedMedia(context, listOf(media)) { populatedMedia: List -> runOnMain { - val (filteredMedia, errors) = getFilteredMedia(context, populatedMedia, mediaConstraints) + val (filteredMedia, errors) = getFilteredMedia( + context, + populatedMedia, + mediaConstraints + ) if (filteredMedia.isEmpty()) { if (errors.contains(Error.ITEM_TOO_LARGE)) { _effects.tryEmit(MediaSendEffect.ShowError(Error.ITEM_TOO_LARGE)) } else if (errors.contains(Error.INVALID_TYPE_ONLY)) { _effects.tryEmit(MediaSendEffect.ShowError(Error.INVALID_TYPE_ONLY)) - }else if (errors.contains(Error.MIXED_TYPE)) { + } else if (errors.contains(Error.MIXED_TYPE)) { _effects.tryEmit(MediaSendEffect.ShowError(Error.MIXED_TYPE)) } } @@ -223,7 +231,8 @@ internal class MediaSendViewModel @Inject constructor( fun onPageChanged(position: Int) { if (position !in selectedMedia.indices) { - Log.w(TAG, + Log.w( + TAG, "Tried to move to an out-of-bounds item. Size: " + selectedMedia.size + ", position: " + position ) return @@ -378,7 +387,7 @@ internal class MediaSendViewModel @Inject constructor( } // if there are no valid types at all, return early - if(validMultiMediaCount == 0){ + if (validMultiMediaCount == 0) { errors.add(Error.INVALID_TYPE_ONLY) return Pair(validMedia, errors) } @@ -427,11 +436,11 @@ internal class MediaSendViewModel @Inject constructor( } } - internal enum class Error { + enum class Error { ITEM_TOO_LARGE, TOO_MANY_ITEMS, INVALID_TYPE_ONLY, MIXED_TYPE } - internal class CountButtonState(val count: Int, private val visibility: Visibility) { + class CountButtonState(val count: Int, private val visibility: Visibility) { val isVisible: Boolean get() { return when (visibility) { @@ -441,7 +450,7 @@ internal class MediaSendViewModel @Inject constructor( } } - internal enum class Visibility { + enum class Visibility { CONDITIONAL, FORCED_ON, FORCED_OFF } } @@ -457,12 +466,13 @@ internal class MediaSendViewModel @Inject constructor( val showCameraButton: Boolean = false ) { val count: Int get() = selectedMedia.size - val showCountButton: Boolean get() = - when (countVisibility) { - CountButtonState.Visibility.FORCED_ON -> true - CountButtonState.Visibility.FORCED_OFF -> false - CountButtonState.Visibility.CONDITIONAL -> count > 0 - } + val showCountButton: Boolean + get() = + when (countVisibility) { + CountButtonState.Visibility.FORCED_ON -> true + CountButtonState.Visibility.FORCED_OFF -> false + CountButtonState.Visibility.CONDITIONAL -> count > 0 + } } sealed interface MediaSendEffect { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt new file mode 100644 index 0000000000..b2abcb1286 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt @@ -0,0 +1,347 @@ +package org.thoughtcrime.securesms.mediasend.compose + +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +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.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import network.loki.messenger.R +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.util.MediaUtil +import kotlin.collections.filterNot +import kotlin.collections.indexOfFirst +import androidx.core.net.toUri + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +fun MediaFolderCell( + title: String, + count: Int, + thumbnailUri: Uri?, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .padding(end = 2.dp, bottom = 2.dp) + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + Box(modifier = Modifier.aspectRatio(1f)) { + GlideImage( + model = thumbnailUri, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + // Bottom shade overlay + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(50.dp) + .background( + Brush.verticalGradient( + colorStops = arrayOf( + 0.0f to Color.Transparent, + 0.5f to Color.Black.copy(alpha = 0.5333f), + 1.0f to Color.Black.copy(alpha = 0.6667f) + ) + ) + ) + ) + // Bottom row + Row( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .padding(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.ic_baseline_folder_24), + contentDescription = null, + modifier = Modifier.size(20.dp), + colorFilter = ColorFilter.tint(Color.White) + ) + + Spacer(Modifier.width(6.dp)) + + Text( + text = title, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) + + Spacer(Modifier.width(6.dp)) + + Text( + text = count.toString(), + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } +} + + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +fun MediaPickerItemCell( + media: Media, + selected: List, + forcedMultiSelect: Boolean, + maxSelection: Int, + onMediaChosen: (Media) -> Unit, + onSelectionStarted: () -> Unit, + onSelectionChanged: (List) -> Unit, + onSelectionOverflow: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val isSelected = selected.any { it.uri == media.uri } + val selectedIndex = remember(selected, media) { + selected.indexOfFirst { it.uri == media.uri } + } + + // Matches adapter rules: + val inSelectionUi = !(selected.isEmpty() && !forcedMultiSelect) + val showSelectOff = inSelectionUi + val showSelectOn = inSelectionUi && isSelected + val showSelectOverlay = isSelected + + val canStartSelectionByLongPress = maxSelection > 1 && selected.isEmpty() && !forcedMultiSelect + + fun removeFromSelection(): List = + selected.filterNot { it.uri == media.uri } + + fun addToSelection(): List = + selected + media + + Box( + modifier = modifier + .padding(end = 2.dp, bottom = 2.dp) + .aspectRatio(1f) + .combinedClickable( + onClick = { + if (selected.isEmpty() && !forcedMultiSelect) { + // adapter: direct choose + onMediaChosen(media) + } else if (isSelected) { + // adapter: remove + onSelectionChanged(removeFromSelection()) + } else { + // adapter: add if room else overflow + if (selected.size < maxSelection) { + onSelectionChanged(addToSelection()) + } else { + onSelectionOverflow(maxSelection) + } + } + }, + onLongClick = if (canStartSelectionByLongPress) { + { + // adapter: long press starts selection, adds this item + onSelectionChanged(listOf(media)) + onSelectionStarted() + } + } else null + ) + ) { + // Thumbnail + GlideImage( + model = media.uri, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + + // Border overlay (replaces @drawable/mediapicker_item_border_dark View) + Box( + Modifier + .matchParentSize() + .border(width = 1.dp, color = Color(0x33000000)) + ) + + // Play overlay (center) for video + if (MediaUtil.isVideoType(media.mimeType)) { + Box( + modifier = Modifier + .align(Alignment.Center) + .size(36.dp) + .clip(CircleShape) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.drawable.triangle_right), + contentDescription = null, + modifier = Modifier + .size(width = 15.dp, height = 18.dp) + .padding(start = 2.dp), + colorFilter = ColorFilter.tint(Color(0xFF2A7BFF)) // match your @color/core_blue-ish + ) + } + } + + // Selection overlay (transparent_black_90) + if (showSelectOverlay) { + Box( + Modifier + .matchParentSize() + .background(Color(0xE6000000)) + ) + } + + // Select OFF badge (top-end) + if (showSelectOff) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + ) { + IndicatorOff(size = dimensionResource(R.dimen.small_radial_size)) + } + } + + // Select ON badge + order number (top-end) + if (showSelectOn) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + IndicatorOn(size = dimensionResource(R.dimen.small_radial_size)) + + Text( + text = (selectedIndex + 1).toString(), + color = LocalColors.current.onInvertedBackgroundAccent, + style = LocalType.current.base, + textAlign = TextAlign.Center + ) + } + } + } +} + +@Composable +private fun IndicatorOff(size: Dp, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .border( + width = Dp.Hairline, + color = LocalColors.current.text, + shape = CircleShape + ) + ) +} + +@Composable +private fun IndicatorOn(size: Dp, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background( + color = LocalColors.current.accent, + shape = CircleShape + ) + ) +} + +@Preview +@Composable +private fun PreviewMediaFolderCell() { + MediaFolderCell( + title = "Test Title", + count = 100, + thumbnailUri = null + ) { } +} + +@Preview(name = "MediaPickerItemCell - Not selected") +@Composable +private fun Preview_MediaPickerItemCell_NotSelected() { + val media = previewMedia("content://preview/media/1", "image/jpeg") + + MediaPickerItemCell( + media = media, + selected = emptyList(), + forcedMultiSelect = false, + maxSelection = 32, + onMediaChosen = {}, + onSelectionStarted = {}, + onSelectionChanged = {}, + onSelectionOverflow = {}, + ) +} + +@Preview(name = "MediaPickerItemCell - Selected (order 1)") +@Composable +private fun Preview_MediaPickerItemCell_Selected() { + val media = previewMedia("content://preview/media/2", "image/jpeg") + + MediaPickerItemCell( + media = media, + selected = listOf(media), // selectedIndex = 0 -> shows "1" + forcedMultiSelect = true, + maxSelection = 32, + onMediaChosen = {}, + onSelectionStarted = {}, + onSelectionChanged = {}, + onSelectionOverflow = {}, + ) +} + +private fun previewMedia(uri: String, mime: String): Media { + return Media( + uri.toUri(), + /* filename = */ "preview", + /* mimeType = */ mime, + /* date = */ 0L, + /* width = */ 100, + /* height = */ 100, + /* size = */ 1234L, + /* bucketId = */ "preview", + /* caption = */ null + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt new file mode 100644 index 0000000000..39b930e47c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt @@ -0,0 +1,119 @@ +package org.thoughtcrime.securesms.mediasend.compose + +import android.annotation.SuppressLint +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.thoughtcrime.securesms.mediasend.MediaFolder +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.theme.LocalColors + +@Composable +fun MediaPickerFolderScreen( + viewModel: MediaSendViewModel, + onFolderClick: (MediaFolder) -> Unit, + title: String, + handleBack: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + MediaPickerFolder( + folders = uiState.folders, + onFolderClick = onFolderClick, + title = title, + handleBack = handleBack + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@SuppressLint("ConfigurationScreenWidthHeight") +@Composable +private fun MediaPickerFolder( + folders: List, + onFolderClick: (folder: MediaFolder) -> Unit, + title: String, + handleBack : () -> Unit +) { + + // span logic: screenWidth / media_picker_folder_width + val folderWidth = dimensionResource(R.dimen.media_picker_folder_width) + val columns = maxOf(1, (LocalConfiguration.current.screenWidthDp.dp / folderWidth).toInt()) + + Scaffold( + topBar = { + BackAppBar( + title = title, + onBack = handleBack, + ) + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues)) { + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = Modifier + .fillMaxSize() + .background(LocalColors.current.background) + ) { + items(folders) { folder -> + MediaFolderCell( + title = folder.title, + count = folder.itemCount, + thumbnailUri = folder.thumbnailUri, + onClick = { onFolderClick(folder) } + ) + } + } + } + } +} + +@Preview +@Composable +private fun MediaPickerFolderPreview() { + MediaPickerFolder( + folders = listOf( + MediaFolder( + title = "Camera", + itemCount = 0, + thumbnailUri = null, + bucketId = "camera" + ), + MediaFolder( + title = "Daily Bugle", + itemCount = 122, + thumbnailUri = null, + bucketId = "daily_bugle" + ), + MediaFolder( + title = "Screenshots", + itemCount = 42, + thumbnailUri = null, + bucketId = "screenshots" + ) + ), + onFolderClick = {}, + title = "Folders", + handleBack = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt new file mode 100644 index 0000000000..443169b379 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt @@ -0,0 +1,211 @@ +package org.thoughtcrime.securesms.mediasend.compose + +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel +import network.loki.messenger.R +import org.session.libsession.utilities.MediaTypes +import org.thoughtcrime.securesms.mediasend.Media.Companion.ALL_MEDIA_BUCKET_ID +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.theme.LocalColors +import androidx.core.net.toUri + +@Composable +fun MediaPickerItemScreen( + viewModel: MediaSendViewModel, + bucketId: String, + title: String, + maxSelection: Int, + onBack: () -> Unit, + onMediaSelected: (Media) -> Unit, // navigate to send screen +) { + val uiState = viewModel.uiState.collectAsState().value + val context = LocalContext.current + + + LaunchedEffect(bucketId) { + viewModel.getMediaInBucket(bucketId) // triggers repository + updates uiState.bucketMedia + viewModel.onItemPickerStarted() + } + + LaunchedEffect(Unit) { + viewModel.effects.collect { eff -> + when (eff) { + is MediaSendViewModel.MediaSendEffect.ShowError -> { + Toast.makeText(context, R.string.attachmentsErrorNumber, Toast.LENGTH_SHORT) + .show() + } + + is MediaSendViewModel.MediaSendEffect.Toast -> + Toast.makeText(context, eff.messageRes, Toast.LENGTH_SHORT).show() + + is MediaSendViewModel.MediaSendEffect.ToastText -> + Toast.makeText(context, eff.message, Toast.LENGTH_SHORT).show() + } + } + } + + MediaPickerItem( + title = title, + media = uiState.bucketMedia, + selected = uiState.selectedMedia, + maxSelection = maxSelection, + showMultiSelectAction = !uiState.showCountButton, + onBack = onBack, + onStartMultiSelect = { viewModel.onMultiSelectStarted() }, + onToggleSelection = { nextSelected -> + viewModel.onSelectedMediaChanged(nextSelected.map { it }) // List + }, + onSinglePick = { media -> + viewModel.onSingleMediaSelected(context, media) + onMediaSelected(media) + } + ) + +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MediaPickerItem( + title: String, + media: List, + selected: List, + maxSelection: Int, + showMultiSelectAction: Boolean, + onBack: () -> Unit, + onStartMultiSelect: () -> Unit, + onToggleSelection: (List) -> Unit, + onSinglePick: (Media) -> Unit, +) { + + // spanCount = screenWidth / itemWidth (same as fragment) + val itemWidth = dimensionResource(R.dimen.media_picker_item_width) + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val columns = maxOf(1, (screenWidth / itemWidth).toInt()) + + var multiSelectMode by rememberSaveable { mutableStateOf(false) } + + Scaffold( + topBar = { + BackAppBar( + title = title, + onBack = onBack, + actions = { + if (showMultiSelectAction) { + IconButton( + onClick = { + multiSelectMode = true + onStartMultiSelect() + } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_plus), + contentDescription = null + ) + } + } + } + ) + }, + ) { padding -> + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = Modifier + .padding(padding) + .fillMaxSize() + .background(LocalColors.current.background) + ) { + items(media, key = { it.uri }) { item -> + MediaPickerItemCell( + media = item, + selected = selected, + forcedMultiSelect = multiSelectMode, // your remembered state / VM flag + maxSelection = maxSelection, + onMediaChosen = { onSinglePick(it) }, + onSelectionStarted = onStartMultiSelect, + onSelectionChanged = { onToggleSelection(it.map { m -> m }) }, + onSelectionOverflow = { /* show toast */ } + ) + } + } + } +} + + +@Preview(name = "Picker - no selection") +@Composable +private fun Preview_MediaPickerItem_NoSelection() { + val media = previewMediaList() + MediaPickerItem( + title = "Screenshots", + media = media, + selected = emptyList(), + maxSelection = 32, + showMultiSelectAction = true, + onBack = {}, + onStartMultiSelect = {}, + onToggleSelection = {}, + onSinglePick = {}, + ) +} + +@Preview(name = "Picker - multi-select with 2 selected") +@Composable +private fun Preview_MediaPickerItem_WithSelection() { + val media = previewMediaList() + val selected = listOf(media[1], media[4]) + + MediaPickerItem( + title = "Camera Roll", + media = media, + selected = selected, + maxSelection = 32, + showMultiSelectAction = false, + onBack = {}, + onStartMultiSelect = {}, + onToggleSelection = {}, + onSinglePick = {}, + ) +} + +private fun previewMediaList(): List { + return (1..12).map { i -> + Media( + "content://preview/media/$i".toUri(), + "preview_$i.jpg", + MediaTypes.IMAGE_JPEG, + /* date */ 0L, + /* width */ 1080, + /* height */ 1080, + /* size */ 1234L, + /* bucketId */ ALL_MEDIA_BUCKET_ID, + /* caption */ null + ) + } +} \ No newline at end of file From 5ac12efa0e79c45334bd7ba1b36fb67de76a241a Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 19 Dec 2025 17:13:56 +0800 Subject: [PATCH 02/18] Initial fragment changes, ComposeViews and minor VM updates --- .../mediasend/MediaPickerFolderAdapter.java | 1 + .../mediasend/MediaPickerFolderFragment.java | 2 + .../mediasend/MediaPickerItemAdapter.java | 1 + .../mediasend/MediaPickerItemFragment.java | 1 + .../securesms/mediasend/MediaSendActivity.kt | 10 ++- .../securesms/mediasend/MediaSendViewModel.kt | 26 ++++-- .../securesms/mediasend/compose/Components.kt | 10 +-- .../MediaPickerFolderComposeFragment.kt | 88 +++++++++++++++++++ .../compose/MediaPickerFolderScreen.kt | 45 +++++++++- .../compose/MediaPickerItemComposeFragment.kt | 71 +++++++++++++++ .../compose/MediaPickerItemScreen.kt | 52 +++-------- 11 files changed, 251 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderComposeFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemComposeFragment.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java index 1973ad1700..a3afc9147a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.List; +@Deprecated class MediaPickerFolderAdapter extends RecyclerView.Adapter { private final RequestManager glideRequests; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java index e34b80dfd1..321e3134a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java @@ -41,6 +41,7 @@ /** * Allows the user to select a media folder to explore. */ +@Deprecated @AndroidEntryPoint public class MediaPickerFolderFragment extends Fragment implements MediaPickerFolderAdapter.EventListener { @@ -191,6 +192,7 @@ public void onFolderClicked(@NonNull MediaFolder folder) { controller.onFolderSelected(folder); } + @Deprecated public interface Controller { void onFolderSelected(@NonNull MediaFolder folder); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java index b184197fe1..f7b2aba8ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java @@ -22,6 +22,7 @@ import java.util.LinkedList; import java.util.List; +@Deprecated public class MediaPickerItemAdapter extends RecyclerView.Adapter { private final RequestManager glideRequests; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java index 1cada541d6..065b4c6659 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java @@ -34,6 +34,7 @@ /** * Allows the user to select a set of media items from a specified folder. */ +@Deprecated @AndroidEntryPoint public class MediaPickerItemFragment extends Fragment implements MediaPickerItemAdapter.EventListener { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt index 1c839902e5..b9e1a0fd52 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt @@ -39,6 +39,8 @@ import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.mediasend.CameraXActivity.Companion.KEY_MEDIA_SEND_COUNT import org.thoughtcrime.securesms.mediasend.MediaSendViewModel.CountButtonState +import org.thoughtcrime.securesms.mediasend.compose.MediaPickerFolderComposeFragment +import org.thoughtcrime.securesms.mediasend.compose.MediaPickerItemComposeFragment import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.scribbles.ImageEditorFragment import org.thoughtcrime.securesms.util.FilenameUtils.constructPhotoFilename @@ -117,7 +119,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme .replace(R.id.mediasend_fragment_container, fragment, TAG_SEND) .commit() } else { - val fragment = MediaPickerFolderFragment.newInstance( + val fragment = MediaPickerFolderComposeFragment.newInstance( recipient!! ) supportFragmentManager.beginTransaction() @@ -179,7 +181,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme override fun onFolderSelected(folder: MediaFolder) { viewModel.onFolderSelected(folder.bucketId) - val fragment = MediaPickerItemFragment.newInstance( + val fragment = MediaPickerItemComposeFragment.newInstance( folder.bucketId, folder.title, MediaSendViewModel.MAX_SELECTED_FILES @@ -208,11 +210,11 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme } override fun onAddMediaClicked(bucketId: String) { - val folderFragment = MediaPickerFolderFragment.newInstance( + val folderFragment = MediaPickerFolderComposeFragment.newInstance( recipient!! ) val itemFragment = - MediaPickerItemFragment.newInstance(bucketId, "", MediaSendViewModel.MAX_SELECTED_FILES) + MediaPickerItemComposeFragment.newInstance(bucketId, "", MediaSendViewModel.MAX_SELECTED_FILES) supportFragmentManager.beginTransaction() .setCustomAnimations( diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt index 247d3f5921..acb8acb8cb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt @@ -140,7 +140,8 @@ class MediaSendViewModel @Inject constructor( it.copy( selectedMedia = filteredMedia, bucketId = computedId, - countVisibility = newVisibility + countVisibility = newVisibility, + forcedMultiSelect = it.forcedMultiSelect && filteredMedia.isNotEmpty() ) } } @@ -174,7 +175,8 @@ class MediaSendViewModel @Inject constructor( it.copy( selectedMedia = filteredMedia, bucketId = newBucketId, - countVisibility = CountButtonState.Visibility.FORCED_OFF + countVisibility = CountButtonState.Visibility.FORCED_OFF, + forcedMultiSelect = false ) } } @@ -182,14 +184,18 @@ class MediaSendViewModel @Inject constructor( } fun onMultiSelectStarted() { - _uiState.update { it.copy(countVisibility = CountButtonState.Visibility.FORCED_ON) } + _uiState.update { it.copy( + countVisibility = CountButtonState.Visibility.FORCED_ON, + forcedMultiSelect = true + ) } } fun onImageEditorStarted() { _uiState.update { it.copy( countVisibility = CountButtonState.Visibility.FORCED_OFF, - showCameraButton = false + showCameraButton = false, + forcedMultiSelect = false ) } } @@ -207,7 +213,8 @@ class MediaSendViewModel @Inject constructor( _uiState.update { it.copy( countVisibility = CountButtonState.Visibility.CONDITIONAL, - showCameraButton = true + showCameraButton = true, + forcedMultiSelect = false ) } } @@ -363,6 +370,12 @@ class MediaSendViewModel @Inject constructor( private val selectedMedia: List get() = _uiState.value.selectedMedia + // Same as getFolders but does not return LiveData + fun refreshFolders() { + repository.getFolders(context) { value -> + _uiState.update { it.copy(folders = value) } + } + } /** * Filters the input list of media. @@ -463,7 +476,8 @@ class MediaSendViewModel @Inject constructor( val selectedMedia: List = emptyList(), val position: Int = -1, val countVisibility: CountButtonState.Visibility = CountButtonState.Visibility.FORCED_OFF, - val showCameraButton: Boolean = false + val showCameraButton: Boolean = false, + val forcedMultiSelect: Boolean = false, // previously in the adapter but put this here for now ) { val count: Int get() = selectedMedia.size val showCountButton: Boolean diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt index b2abcb1286..96437ad9d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt @@ -196,7 +196,7 @@ fun MediaPickerItemCell( Box( Modifier .matchParentSize() - .border(width = 1.dp, color = Color(0x33000000)) + .border(width = 1.dp, color = Color.White.copy(alpha = 0.20f)) ) // Play overlay (center) for video @@ -215,17 +215,17 @@ fun MediaPickerItemCell( modifier = Modifier .size(width = 15.dp, height = 18.dp) .padding(start = 2.dp), - colorFilter = ColorFilter.tint(Color(0xFF2A7BFF)) // match your @color/core_blue-ish + colorFilter = ColorFilter.tint(LocalColors.current.accent) // match @color/core_blue-ish ) } } - // Selection overlay (transparent_black_90) + // Selection overlay if (showSelectOverlay) { Box( Modifier .matchParentSize() - .background(Color(0xE6000000)) + .background(Color.Black.copy(alpha = 0.80f)) ) } @@ -268,7 +268,7 @@ private fun IndicatorOff(size: Dp, modifier: Modifier = Modifier) { .size(size) .clip(CircleShape) .border( - width = Dp.Hairline, + width = 1.dp, color = LocalColors.current.text, shape = CircleShape ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderComposeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderComposeFragment.kt new file mode 100644 index 0000000000..637e1eaf65 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderComposeFragment.kt @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.mediasend.compose + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import com.squareup.phrase.Phrase +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.displayName +import org.thoughtcrime.securesms.mediasend.MediaPickerFolderFragment +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel +import org.thoughtcrime.securesms.ui.setThemedContent + +@AndroidEntryPoint +class MediaPickerFolderComposeFragment : Fragment() { + + private val viewModel: MediaSendViewModel by activityViewModels() + + private var recipientName: String? = null + private var controller: MediaPickerFolderFragment.Controller? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + controller = activity as? MediaPickerFolderFragment.Controller + ?: throw IllegalStateException("Parent activity must implement controller class.") + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + recipientName = requireArguments().getString(KEY_RECIPIENT_NAME) + } + + override fun onResume() { + super.onResume() + viewModel.onFolderPickerStarted() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setThemedContent { + val ctx = LocalContext.current + // Same title as the old toolbar + val title = remember(recipientName) { + Phrase.from(ctx, R.string.attachmentsSendTo) + .put(StringSubstitutionConstants.NAME_KEY, recipientName ?: "") + .format() + .toString() + } + + MediaPickerFolderScreen( + viewModel = viewModel, + title = title, + handleBack = { + requireActivity().onBackPressedDispatcher.onBackPressed() + }, + onFolderClick = { folder -> + controller?.onFolderSelected(folder) + } + ) + } + } + } + + companion object { + private const val KEY_RECIPIENT_NAME = "recipient_name" + + fun newInstance(recipient: Recipient): MediaPickerFolderComposeFragment { + return MediaPickerFolderComposeFragment().apply { + arguments = Bundle().apply { + putString(KEY_RECIPIENT_NAME, recipient.displayName(false)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt index 39b930e47c..3a407c9bb8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt @@ -13,16 +13,25 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.viewmodel.compose.viewModel import network.loki.messenger.R +import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager import org.thoughtcrime.securesms.mediasend.MediaFolder import org.thoughtcrime.securesms.mediasend.MediaSendViewModel import org.thoughtcrime.securesms.ui.components.BackAppBar @@ -37,11 +46,17 @@ fun MediaPickerFolderScreen( ) { val uiState by viewModel.uiState.collectAsState() + LaunchedEffect(Unit) { + viewModel.refreshFolders() + viewModel.onFolderPickerStarted() + } + MediaPickerFolder( folders = uiState.folders, onFolderClick = onFolderClick, title = title, - handleBack = handleBack + handleBack = handleBack, + refreshFolders = { viewModel.refreshFolders() } ) } @@ -52,18 +67,41 @@ private fun MediaPickerFolder( folders: List, onFolderClick: (folder: MediaFolder) -> Unit, title: String, - handleBack : () -> Unit + handleBack: () -> Unit, + refreshFolders: () -> Unit ) { // span logic: screenWidth / media_picker_folder_width val folderWidth = dimensionResource(R.dimen.media_picker_folder_width) val columns = maxOf(1, (LocalConfiguration.current.screenWidthDp.dp / folderWidth).toInt()) + val context = LocalContext.current + val activity = context as? FragmentActivity + val showManage = remember(activity) { + activity?.let { AttachmentManager.shouldShowManagePhoto(it) } == true + } + Scaffold( topBar = { BackAppBar( title = title, onBack = handleBack, + actions = { + if (showManage && activity != null) { + IconButton( + onClick = { + AttachmentManager.managePhotoAccess(activity) { + refreshFolders() + } + } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_plus), + contentDescription = null + ) + } + } + } ) }, contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), @@ -114,6 +152,7 @@ private fun MediaPickerFolderPreview() { ), onFolderClick = {}, title = "Folders", - handleBack = {} + handleBack = {}, + refreshFolders = {} ) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemComposeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemComposeFragment.kt new file mode 100644 index 0000000000..abd6271503 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemComposeFragment.kt @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.mediasend.compose + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import dagger.hilt.android.AndroidEntryPoint +import org.thoughtcrime.securesms.mediasend.MediaPickerItemFragment +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel +import org.thoughtcrime.securesms.ui.setThemedContent + +@AndroidEntryPoint +class MediaPickerItemComposeFragment : Fragment() { + + private val viewModel: MediaSendViewModel by activityViewModels() + + private var controller: MediaPickerItemFragment.Controller? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + controller = activity as? MediaPickerItemFragment.Controller + ?: throw IllegalStateException("Parent activity must implement controller class.") + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val bucketId = requireArguments().getString(ARG_BUCKET_ID)!! + val title = requireArguments().getString(ARG_TITLE)!! + val maxSelection = requireArguments().getInt(ARG_MAX_SELECTION) + + return ComposeView(requireContext()).apply { + setThemedContent { + MediaPickerItemScreen( + viewModel = viewModel, + bucketId = bucketId, + title = title, + maxSelection = maxSelection, + onBack = { requireActivity().onBackPressedDispatcher.onBackPressed() }, + onMediaSelected = { media -> + // Exact same path as old fragment -> Activity + controller?.onMediaSelected(media) + } + ) + } + } + } + + companion object { + private const val ARG_BUCKET_ID = "bucket_id" + private const val ARG_TITLE = "title" + private const val ARG_MAX_SELECTION = "max_selection" + + @JvmStatic + fun newInstance(bucketId: String, title: String, maxSelection: Int) = + MediaPickerItemComposeFragment().apply { + arguments = bundleOf( + ARG_BUCKET_ID to bucketId, + ARG_TITLE to title, + ARG_MAX_SELECTION to maxSelection + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt index 443169b379..37746aba30 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt @@ -1,7 +1,5 @@ package org.thoughtcrime.securesms.mediasend.compose -import android.net.Uri -import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -15,10 +13,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext @@ -26,15 +20,14 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import org.thoughtcrime.securesms.mediasend.Media -import org.thoughtcrime.securesms.mediasend.MediaSendViewModel +import androidx.core.net.toUri import network.loki.messenger.R import org.session.libsession.utilities.MediaTypes +import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.Media.Companion.ALL_MEDIA_BUCKET_ID +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.theme.LocalColors -import androidx.core.net.toUri @Composable fun MediaPickerItemScreen( @@ -48,29 +41,11 @@ fun MediaPickerItemScreen( val uiState = viewModel.uiState.collectAsState().value val context = LocalContext.current - LaunchedEffect(bucketId) { viewModel.getMediaInBucket(bucketId) // triggers repository + updates uiState.bucketMedia viewModel.onItemPickerStarted() } - LaunchedEffect(Unit) { - viewModel.effects.collect { eff -> - when (eff) { - is MediaSendViewModel.MediaSendEffect.ShowError -> { - Toast.makeText(context, R.string.attachmentsErrorNumber, Toast.LENGTH_SHORT) - .show() - } - - is MediaSendViewModel.MediaSendEffect.Toast -> - Toast.makeText(context, eff.messageRes, Toast.LENGTH_SHORT).show() - - is MediaSendViewModel.MediaSendEffect.ToastText -> - Toast.makeText(context, eff.message, Toast.LENGTH_SHORT).show() - } - } - } - MediaPickerItem( title = title, media = uiState.bucketMedia, @@ -78,14 +53,16 @@ fun MediaPickerItemScreen( maxSelection = maxSelection, showMultiSelectAction = !uiState.showCountButton, onBack = onBack, - onStartMultiSelect = { viewModel.onMultiSelectStarted() }, + onStartMultiSelect = { + viewModel.onMultiSelectStarted() + }, onToggleSelection = { nextSelected -> - viewModel.onSelectedMediaChanged(nextSelected.map { it }) // List + viewModel.onSelectedMediaChanged(nextSelected) // List }, onSinglePick = { media -> - viewModel.onSingleMediaSelected(context, media) onMediaSelected(media) - } + }, + forcedMultiSelect = uiState.forcedMultiSelect ) } @@ -102,6 +79,7 @@ private fun MediaPickerItem( onStartMultiSelect: () -> Unit, onToggleSelection: (List) -> Unit, onSinglePick: (Media) -> Unit, + forcedMultiSelect: Boolean = false ) { // spanCount = screenWidth / itemWidth (same as fragment) @@ -109,9 +87,8 @@ private fun MediaPickerItem( val screenWidth = LocalConfiguration.current.screenWidthDp.dp val columns = maxOf(1, (screenWidth / itemWidth).toInt()) - var multiSelectMode by rememberSaveable { mutableStateOf(false) } - Scaffold( + modifier = Modifier.background(LocalColors.current.background), topBar = { BackAppBar( title = title, @@ -120,12 +97,11 @@ private fun MediaPickerItem( if (showMultiSelectAction) { IconButton( onClick = { - multiSelectMode = true onStartMultiSelect() } ) { Icon( - painter = painterResource(id = R.drawable.ic_plus), + painter = painterResource(id = R.drawable.ic_images), contentDescription = null ) } @@ -145,11 +121,11 @@ private fun MediaPickerItem( MediaPickerItemCell( media = item, selected = selected, - forcedMultiSelect = multiSelectMode, // your remembered state / VM flag + forcedMultiSelect = forcedMultiSelect, maxSelection = maxSelection, onMediaChosen = { onSinglePick(it) }, onSelectionStarted = onStartMultiSelect, - onSelectionChanged = { onToggleSelection(it.map { m -> m }) }, + onSelectionChanged = onToggleSelection, onSelectionOverflow = { /* show toast */ } ) } From 414feede59de2e68e0284ae70245bbe3eff55150 Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 19 Dec 2025 17:17:12 +0800 Subject: [PATCH 03/18] Fixed wrong flag set --- .../thoughtcrime/securesms/mediasend/MediaSendViewModel.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt index acb8acb8cb..211d02b1bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt @@ -194,8 +194,7 @@ class MediaSendViewModel @Inject constructor( _uiState.update { it.copy( countVisibility = CountButtonState.Visibility.FORCED_OFF, - showCameraButton = false, - forcedMultiSelect = false + showCameraButton = false ) } } @@ -213,8 +212,7 @@ class MediaSendViewModel @Inject constructor( _uiState.update { it.copy( countVisibility = CountButtonState.Visibility.CONDITIONAL, - showCameraButton = true, - forcedMultiSelect = false + showCameraButton = true ) } } From 0e694155c357e024f80eb1426b80dd0f866e144b Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 19 Dec 2025 17:27:15 +0800 Subject: [PATCH 04/18] SelectionOverflow error --- .../mediasend/compose/MediaPickerItemScreen.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt index 37746aba30..540235dc8b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.mediasend.compose +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -20,6 +21,7 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.content.ContentProviderCompat.requireContext import androidx.core.net.toUri import network.loki.messenger.R import org.session.libsession.utilities.MediaTypes @@ -82,6 +84,7 @@ private fun MediaPickerItem( forcedMultiSelect: Boolean = false ) { + val context = LocalContext.current.applicationContext // spanCount = screenWidth / itemWidth (same as fragment) val itemWidth = dimensionResource(R.dimen.media_picker_item_width) val screenWidth = LocalConfiguration.current.screenWidthDp.dp @@ -126,7 +129,13 @@ private fun MediaPickerItem( onMediaChosen = { onSinglePick(it) }, onSelectionStarted = onStartMultiSelect, onSelectionChanged = onToggleSelection, - onSelectionOverflow = { /* show toast */ } + onSelectionOverflow = { + Toast.makeText( + context, + R.string.attachmentsErrorNumber, + Toast.LENGTH_SHORT + ).show() + } ) } } From 76fd508cf5fb29100fabbfdf958d96df3cd0fc9c Mon Sep 17 00:00:00 2001 From: jbsession Date: Wed, 7 Jan 2026 11:26:44 +0800 Subject: [PATCH 05/18] Updated hardcoded dimensions, other compose changes --- .../securesms/mediasend/compose/Components.kt | 93 +++++++++---------- .../compose/MediaPickerFolderScreen.kt | 14 ++- .../compose/MediaPickerItemScreen.kt | 9 +- .../securesms/ui/theme/Dimensions.kt | 8 +- 4 files changed, 66 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt index 96437ad9d5..0225fddfd3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt @@ -45,6 +45,7 @@ import org.thoughtcrime.securesms.util.MediaUtil import kotlin.collections.filterNot import kotlin.collections.indexOfFirst import androidx.core.net.toUri +import org.thoughtcrime.securesms.ui.theme.LocalDimensions @OptIn(ExperimentalGlideComposeApi::class) @Composable @@ -56,7 +57,6 @@ fun MediaFolderCell( ) { Box( modifier = Modifier - .padding(end = 2.dp, bottom = 2.dp) .fillMaxWidth() .clickable(onClick = onClick) ) { @@ -72,50 +72,49 @@ fun MediaFolderCell( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() - .height(50.dp) .background( Brush.verticalGradient( colorStops = arrayOf( 0.0f to Color.Transparent, - 0.5f to Color.Black.copy(alpha = 0.5333f), - 1.0f to Color.Black.copy(alpha = 0.6667f) + 0.5f to Color.Black.copy(alpha = 0.5f), + 1.0f to Color.Black.copy(alpha = 0.7f) ) ) ) - ) - // Bottom row - Row( - modifier = Modifier - .align(Alignment.BottomStart) - .fillMaxWidth() - .padding(6.dp), - verticalAlignment = Alignment.CenterVertically + .padding(LocalDimensions.current.smallSpacing) ) { - Image( - painter = painterResource(R.drawable.ic_baseline_folder_24), - contentDescription = null, - modifier = Modifier.size(20.dp), - colorFilter = ColorFilter.tint(Color.White) - ) + // Bottom row + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.ic_baseline_folder_24), + contentDescription = null, + modifier = Modifier.size(LocalDimensions.current.iconSmall), + colorFilter = ColorFilter.tint(Color.White) + ) - Spacer(Modifier.width(6.dp)) + Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) - Text( - text = title, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = Color.White, - style = MaterialTheme.typography.bodyMedium - ) + Text( + text = title, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) - Spacer(Modifier.width(6.dp)) + Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) - Text( - text = count.toString(), - color = Color.White, - style = MaterialTheme.typography.bodyMedium - ) + Text( + text = count.toString(), + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) + } } } } @@ -156,8 +155,11 @@ fun MediaPickerItemCell( Box( modifier = modifier - .padding(end = 2.dp, bottom = 2.dp) .aspectRatio(1f) + .border( + width = LocalDimensions.current.borderStroke, + color = Color.White.copy(alpha = 0.20f) + ) .combinedClickable( onClick = { if (selected.isEmpty() && !forcedMultiSelect) { @@ -192,19 +194,12 @@ fun MediaPickerItemCell( contentScale = ContentScale.Crop ) - // Border overlay (replaces @drawable/mediapicker_item_border_dark View) - Box( - Modifier - .matchParentSize() - .border(width = 1.dp, color = Color.White.copy(alpha = 0.20f)) - ) - // Play overlay (center) for video if (MediaUtil.isVideoType(media.mimeType)) { Box( modifier = Modifier .align(Alignment.Center) - .size(36.dp) + .size(LocalDimensions.current.mediaPlayOverlay) .clip(CircleShape) .background(Color.White), contentAlignment = Alignment.Center @@ -212,9 +207,7 @@ fun MediaPickerItemCell( Image( painter = painterResource(R.drawable.triangle_right), contentDescription = null, - modifier = Modifier - .size(width = 15.dp, height = 18.dp) - .padding(start = 2.dp), + modifier = Modifier.size(LocalDimensions.current.iconMedium), colorFilter = ColorFilter.tint(LocalColors.current.accent) // match @color/core_blue-ish ) } @@ -234,9 +227,9 @@ fun MediaPickerItemCell( Box( modifier = Modifier .align(Alignment.TopEnd) - .padding(6.dp) + .padding(LocalDimensions.current.xxsSpacing) ) { - IndicatorOff(size = dimensionResource(R.dimen.small_radial_size)) + IndicatorOff(size = LocalDimensions.current.smallRadius) } } @@ -245,10 +238,10 @@ fun MediaPickerItemCell( Box( modifier = Modifier .align(Alignment.TopEnd) - .padding(6.dp), + .padding(LocalDimensions.current.xxsSpacing), contentAlignment = Alignment.Center ) { - IndicatorOn(size = dimensionResource(R.dimen.small_radial_size)) + IndicatorOn(size = LocalDimensions.current.smallRadius) Text( text = (selectedIndex + 1).toString(), @@ -268,7 +261,7 @@ private fun IndicatorOff(size: Dp, modifier: Modifier = Modifier) { .size(size) .clip(CircleShape) .border( - width = 1.dp, + width = LocalDimensions.current.borderStroke, color = LocalColors.current.text, shape = CircleShape ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt index 3a407c9bb8..e1722479f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.mediasend.compose import android.annotation.SuppressLint import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides @@ -29,13 +30,13 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.viewmodel.compose.viewModel import network.loki.messenger.R import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager import org.thoughtcrime.securesms.mediasend.MediaFolder import org.thoughtcrime.securesms.mediasend.MediaSendViewModel import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions @Composable fun MediaPickerFolderScreen( @@ -111,14 +112,19 @@ private fun MediaPickerFolder( columns = GridCells.Fixed(columns), modifier = Modifier .fillMaxSize() - .background(LocalColors.current.background) + .background(LocalColors.current.background), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.mediaItemGridSpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.mediaItemGridSpacing) ) { - items(folders) { folder -> + items( + items = folders, + key = { folder -> folder.bucketId } + ) { folder -> MediaFolderCell( title = folder.title, count = folder.itemCount, thumbnailUri = folder.thumbnailUri, - onClick = { onFolderClick(folder) } + onClick = { onFolderClick(folder) }, ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt index 540235dc8b..8be3148e4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.mediasend.compose import android.widget.Toast import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells @@ -30,6 +31,7 @@ import org.thoughtcrime.securesms.mediasend.Media.Companion.ALL_MEDIA_BUCKET_ID import org.thoughtcrime.securesms.mediasend.MediaSendViewModel import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions @Composable fun MediaPickerItemScreen( @@ -85,8 +87,7 @@ private fun MediaPickerItem( ) { val context = LocalContext.current.applicationContext - // spanCount = screenWidth / itemWidth (same as fragment) - val itemWidth = dimensionResource(R.dimen.media_picker_item_width) + val itemWidth = LocalDimensions.current.mediaPickerItemWidth val screenWidth = LocalConfiguration.current.screenWidthDp.dp val columns = maxOf(1, (screenWidth / itemWidth).toInt()) @@ -118,7 +119,9 @@ private fun MediaPickerItem( modifier = Modifier .padding(padding) .fillMaxSize() - .background(LocalColors.current.background) + .background(LocalColors.current.background), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.mediaItemGridSpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.mediaItemGridSpacing) ) { items(media, key = { it.uri }) { item -> MediaPickerItemCell( diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt index 18746d21f5..acbb0c69a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt @@ -52,5 +52,11 @@ data class Dimensions( val minContentSize: Dp = 80.dp, val maxContentSize: Dp = 520.dp, val minContentSizeMedium: Dp = 160.dp, - val maxContentSizeMedium: Dp = 620.dp + val maxContentSizeMedium: Dp = 620.dp, + + val mediaPickerItemWidth : Dp = 85.dp, + val mediaItemGridSpacing : Dp = 2.dp, + val mediaPlayOverlay : Dp = 36.dp, + + val smallRadius : Dp = 26.dp ) From 6ced08d8c3738e0396468176e64b4bcec90eb474 Mon Sep 17 00:00:00 2001 From: jbsession Date: Wed, 7 Jan 2026 17:04:30 +0800 Subject: [PATCH 06/18] Moved most logic to viewmodel --- .../securesms/mediasend/MediaSendActivity.kt | 7 +- .../securesms/mediasend/MediaSendViewModel.kt | 43 +++++- .../securesms/mediasend/compose/Components.kt | 146 +++++++----------- .../compose/MediaPickerItemComposeFragment.kt | 15 +- .../compose/MediaPickerItemScreen.kt | 46 +++--- 5 files changed, 124 insertions(+), 133 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt index b9e1a0fd52..e37a8a784c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt @@ -56,7 +56,7 @@ import javax.inject.Inject */ @AndroidEntryPoint class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragment.Controller, - MediaPickerItemFragment.Controller, MediaSendFragment.Controller, + MediaPickerItemComposeFragment.Controller, MediaSendFragment.Controller, ImageEditorFragment.Controller { private var recipient: Recipient? = null @@ -183,8 +183,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme val fragment = MediaPickerItemComposeFragment.newInstance( folder.bucketId, - folder.title, - MediaSendViewModel.MAX_SELECTED_FILES + folder.title ) supportFragmentManager.beginTransaction() .setCustomAnimations( @@ -214,7 +213,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme recipient!! ) val itemFragment = - MediaPickerItemComposeFragment.newInstance(bucketId, "", MediaSendViewModel.MAX_SELECTED_FILES) + MediaPickerItemComposeFragment.newInstance(bucketId, "") supportFragmentManager.beginTransaction() .setCustomAnimations( diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt index 211d02b1bb..c9ac68b8f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt @@ -91,6 +91,26 @@ class MediaSendViewModel @Inject constructor( ) } + fun onMediaSelected(media: Media) { + val updatedList = run { + val current = uiState.value.selectedMedia + val exists = current.any { it.uri == media.uri } + + if (exists) { + current.filterNot { it.uri == media.uri } + } else { + if (current.size >= MAX_SELECTED_FILES) { + _effects.tryEmit(MediaSendEffect.ShowError(Error.TOO_MANY_ITEMS)) + current + } else { + current + media + } + } + } + + onSelectedMediaChanged(updatedList) + } + fun onSelectedMediaChanged(newMedia: List) { repository.getPopulatedMedia(context, newMedia) { populatedMedia: List -> runOnMain { @@ -141,7 +161,6 @@ class MediaSendViewModel @Inject constructor( selectedMedia = filteredMedia, bucketId = computedId, countVisibility = newVisibility, - forcedMultiSelect = it.forcedMultiSelect && filteredMedia.isNotEmpty() ) } } @@ -176,7 +195,6 @@ class MediaSendViewModel @Inject constructor( selectedMedia = filteredMedia, bucketId = newBucketId, countVisibility = CountButtonState.Visibility.FORCED_OFF, - forcedMultiSelect = false ) } } @@ -184,10 +202,11 @@ class MediaSendViewModel @Inject constructor( } fun onMultiSelectStarted() { - _uiState.update { it.copy( - countVisibility = CountButtonState.Visibility.FORCED_ON, - forcedMultiSelect = true - ) } + _uiState.update { + it.copy( + countVisibility = CountButtonState.Visibility.FORCED_ON + ) + } } fun onImageEditorStarted() { @@ -386,6 +405,11 @@ class MediaSendViewModel @Inject constructor( media: List, mediaConstraints: MediaConstraints ): Pair, Set> { + + if (media.isEmpty()) { + return Pair(emptyList(), emptySet()) + } + val validMedia = ArrayList() val errors = HashSet() @@ -475,9 +499,14 @@ class MediaSendViewModel @Inject constructor( val position: Int = -1, val countVisibility: CountButtonState.Visibility = CountButtonState.Visibility.FORCED_OFF, val showCameraButton: Boolean = false, - val forcedMultiSelect: Boolean = false, // previously in the adapter but put this here for now ) { val count: Int get() = selectedMedia.size + + val isMultiSelect: Boolean + get() = selectedMedia.isNotEmpty() || countVisibility == CountButtonState.Visibility.FORCED_ON + + val canLongPress: Boolean + get() = selectedMedia.isEmpty() && !isMultiSelect val showCountButton: Boolean get() = when (countVisibility) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt index 0225fddfd3..a9b734bd4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize 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 @@ -20,7 +19,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -28,13 +26,12 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage import network.loki.messenger.R @@ -43,8 +40,9 @@ import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.util.MediaUtil import kotlin.collections.filterNot -import kotlin.collections.indexOfFirst import androidx.core.net.toUri +import coil3.compose.AsyncImage +import coil3.request.ImageRequest import org.thoughtcrime.securesms.ui.theme.LocalDimensions @OptIn(ExperimentalGlideComposeApi::class) @@ -61,12 +59,15 @@ fun MediaFolderCell( .clickable(onClick = onClick) ) { Box(modifier = Modifier.aspectRatio(1f)) { - GlideImage( - model = thumbnailUri, + AsyncImage( + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Crop, + model = ImageRequest.Builder(LocalContext.current) + .data(thumbnailUri) + .build(), contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop ) + // Bottom shade overlay Box( modifier = Modifier @@ -125,34 +126,16 @@ fun MediaFolderCell( @Composable fun MediaPickerItemCell( media: Media, - selected: List, - forcedMultiSelect: Boolean, - maxSelection: Int, + isSelected: Boolean = false, + selectedIndex: Int = 1, + isMultiSelect: Boolean, onMediaChosen: (Media) -> Unit, onSelectionStarted: () -> Unit, - onSelectionChanged: (List) -> Unit, - onSelectionOverflow: (Int) -> Unit, + onSelectionChanged: (selectedMedia: Media) -> Unit, modifier: Modifier = Modifier, + showSelectionOn: Boolean = false, + canLongPress: Boolean = true ) { - val isSelected = selected.any { it.uri == media.uri } - val selectedIndex = remember(selected, media) { - selected.indexOfFirst { it.uri == media.uri } - } - - // Matches adapter rules: - val inSelectionUi = !(selected.isEmpty() && !forcedMultiSelect) - val showSelectOff = inSelectionUi - val showSelectOn = inSelectionUi && isSelected - val showSelectOverlay = isSelected - - val canStartSelectionByLongPress = maxSelection > 1 && selected.isEmpty() && !forcedMultiSelect - - fun removeFromSelection(): List = - selected.filterNot { it.uri == media.uri } - - fun addToSelection(): List = - selected + media - Box( modifier = modifier .aspectRatio(1f) @@ -162,36 +145,29 @@ fun MediaPickerItemCell( ) .combinedClickable( onClick = { - if (selected.isEmpty() && !forcedMultiSelect) { - // adapter: direct choose - onMediaChosen(media) - } else if (isSelected) { - // adapter: remove - onSelectionChanged(removeFromSelection()) + if (!isMultiSelect) { + onMediaChosen(media) // Choosing a single media } else { - // adapter: add if room else overflow - if (selected.size < maxSelection) { - onSelectionChanged(addToSelection()) - } else { - onSelectionOverflow(maxSelection) - } + onSelectionChanged(media) // Selecting/unselecting media } }, - onLongClick = if (canStartSelectionByLongPress) { + onLongClick = if (canLongPress) { { - // adapter: long press starts selection, adds this item - onSelectionChanged(listOf(media)) + // long press starts selection, adds this item + onSelectionChanged(media) onSelectionStarted() } } else null ) ) { // Thumbnail - GlideImage( - model = media.uri, - contentDescription = null, + AsyncImage( modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + contentScale = ContentScale.Crop, + model = ImageRequest.Builder(LocalContext.current) + .data(media.uri) + .build(), + contentDescription = null, ) // Play overlay (center) for video @@ -214,7 +190,7 @@ fun MediaPickerItemCell( } // Selection overlay - if (showSelectOverlay) { + if (isSelected) { Box( Modifier .matchParentSize() @@ -222,33 +198,33 @@ fun MediaPickerItemCell( ) } - // Select OFF badge (top-end) - if (showSelectOff) { - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(LocalDimensions.current.xxsSpacing) - ) { - IndicatorOff(size = LocalDimensions.current.smallRadius) - } - } - - // Select ON badge + order number (top-end) - if (showSelectOn) { - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(LocalDimensions.current.xxsSpacing), - contentAlignment = Alignment.Center - ) { - IndicatorOn(size = LocalDimensions.current.smallRadius) + if (isMultiSelect) { + // Select ON badge + order number (top-end) + if (showSelectionOn) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(LocalDimensions.current.xxsSpacing), + contentAlignment = Alignment.Center + ) { + IndicatorOn(size = LocalDimensions.current.smallRadius) - Text( - text = (selectedIndex + 1).toString(), - color = LocalColors.current.onInvertedBackgroundAccent, - style = LocalType.current.base, - textAlign = TextAlign.Center - ) + Text( + text = (selectedIndex + 1).toString(), + color = LocalColors.current.onInvertedBackgroundAccent, + style = LocalType.current.base, + textAlign = TextAlign.Center + ) + } + } else { + // Select OFF badge + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(LocalDimensions.current.xxsSpacing) + ) { + IndicatorOff(size = LocalDimensions.current.smallRadius) + } } } } @@ -298,13 +274,11 @@ private fun Preview_MediaPickerItemCell_NotSelected() { MediaPickerItemCell( media = media, - selected = emptyList(), - forcedMultiSelect = false, - maxSelection = 32, + isMultiSelect = false, + canLongPress = true, onMediaChosen = {}, onSelectionStarted = {}, onSelectionChanged = {}, - onSelectionOverflow = {}, ) } @@ -315,13 +289,11 @@ private fun Preview_MediaPickerItemCell_Selected() { MediaPickerItemCell( media = media, - selected = listOf(media), // selectedIndex = 0 -> shows "1" - forcedMultiSelect = true, - maxSelection = 32, + isMultiSelect = true, + canLongPress = true, onMediaChosen = {}, onSelectionStarted = {}, onSelectionChanged = {}, - onSelectionOverflow = {}, ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemComposeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemComposeFragment.kt index abd6271503..a891f9f7b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemComposeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemComposeFragment.kt @@ -10,7 +10,7 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import dagger.hilt.android.AndroidEntryPoint -import org.thoughtcrime.securesms.mediasend.MediaPickerItemFragment +import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.MediaSendViewModel import org.thoughtcrime.securesms.ui.setThemedContent @@ -19,11 +19,11 @@ class MediaPickerItemComposeFragment : Fragment() { private val viewModel: MediaSendViewModel by activityViewModels() - private var controller: MediaPickerItemFragment.Controller? = null + private var controller: Controller? = null override fun onAttach(context: Context) { super.onAttach(context) - controller = activity as? MediaPickerItemFragment.Controller + controller = activity as? Controller ?: throw IllegalStateException("Parent activity must implement controller class.") } @@ -34,7 +34,6 @@ class MediaPickerItemComposeFragment : Fragment() { ): View { val bucketId = requireArguments().getString(ARG_BUCKET_ID)!! val title = requireArguments().getString(ARG_TITLE)!! - val maxSelection = requireArguments().getInt(ARG_MAX_SELECTION) return ComposeView(requireContext()).apply { setThemedContent { @@ -42,7 +41,6 @@ class MediaPickerItemComposeFragment : Fragment() { viewModel = viewModel, bucketId = bucketId, title = title, - maxSelection = maxSelection, onBack = { requireActivity().onBackPressedDispatcher.onBackPressed() }, onMediaSelected = { media -> // Exact same path as old fragment -> Activity @@ -59,13 +57,16 @@ class MediaPickerItemComposeFragment : Fragment() { private const val ARG_MAX_SELECTION = "max_selection" @JvmStatic - fun newInstance(bucketId: String, title: String, maxSelection: Int) = + fun newInstance(bucketId: String, title: String) = MediaPickerItemComposeFragment().apply { arguments = bundleOf( ARG_BUCKET_ID to bucketId, ARG_TITLE to title, - ARG_MAX_SELECTION to maxSelection ) } } + + interface Controller { + fun onMediaSelected(media: Media) + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt index 8be3148e4a..e9b5a5e5a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.mediasend.compose -import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxSize @@ -18,11 +17,9 @@ import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.core.content.ContentProviderCompat.requireContext import androidx.core.net.toUri import network.loki.messenger.R import org.session.libsession.utilities.MediaTypes @@ -38,12 +35,10 @@ fun MediaPickerItemScreen( viewModel: MediaSendViewModel, bucketId: String, title: String, - maxSelection: Int, onBack: () -> Unit, onMediaSelected: (Media) -> Unit, // navigate to send screen ) { val uiState = viewModel.uiState.collectAsState().value - val context = LocalContext.current LaunchedEffect(bucketId) { viewModel.getMediaInBucket(bucketId) // triggers repository + updates uiState.bucketMedia @@ -53,20 +48,20 @@ fun MediaPickerItemScreen( MediaPickerItem( title = title, media = uiState.bucketMedia, - selected = uiState.selectedMedia, - maxSelection = maxSelection, + selectedMedia = uiState.selectedMedia, + canLongPress = uiState.canLongPress, showMultiSelectAction = !uiState.showCountButton, onBack = onBack, onStartMultiSelect = { viewModel.onMultiSelectStarted() }, onToggleSelection = { nextSelected -> - viewModel.onSelectedMediaChanged(nextSelected) // List + viewModel.onMediaSelected(nextSelected) // List }, onSinglePick = { media -> onMediaSelected(media) }, - forcedMultiSelect = uiState.forcedMultiSelect + isMultiSelect = uiState.isMultiSelect ) } @@ -76,17 +71,16 @@ fun MediaPickerItemScreen( private fun MediaPickerItem( title: String, media: List, - selected: List, - maxSelection: Int, + selectedMedia: List, + canLongPress: Boolean, showMultiSelectAction: Boolean, onBack: () -> Unit, onStartMultiSelect: () -> Unit, - onToggleSelection: (List) -> Unit, + onToggleSelection: (selectedMedia: Media) -> Unit, onSinglePick: (Media) -> Unit, - forcedMultiSelect: Boolean = false + isMultiSelect: Boolean = false ) { - val context = LocalContext.current.applicationContext val itemWidth = LocalDimensions.current.mediaPickerItemWidth val screenWidth = LocalConfiguration.current.screenWidthDp.dp val columns = maxOf(1, (screenWidth / itemWidth).toInt()) @@ -124,21 +118,17 @@ private fun MediaPickerItem( verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.mediaItemGridSpacing) ) { items(media, key = { it.uri }) { item -> + val isSelected = selectedMedia.any { it.uri == item.uri } MediaPickerItemCell( media = item, - selected = selected, - forcedMultiSelect = forcedMultiSelect, - maxSelection = maxSelection, + isSelected = isSelected, + selectedIndex = selectedMedia.indexOfFirst { it.uri == item.uri }, + isMultiSelect = isMultiSelect, + canLongPress = canLongPress, + showSelectionOn = isSelected, onMediaChosen = { onSinglePick(it) }, onSelectionStarted = onStartMultiSelect, onSelectionChanged = onToggleSelection, - onSelectionOverflow = { - Toast.makeText( - context, - R.string.attachmentsErrorNumber, - Toast.LENGTH_SHORT - ).show() - } ) } } @@ -153,8 +143,8 @@ private fun Preview_MediaPickerItem_NoSelection() { MediaPickerItem( title = "Screenshots", media = media, - selected = emptyList(), - maxSelection = 32, + selectedMedia = emptyList(), + canLongPress = true, showMultiSelectAction = true, onBack = {}, onStartMultiSelect = {}, @@ -172,8 +162,8 @@ private fun Preview_MediaPickerItem_WithSelection() { MediaPickerItem( title = "Camera Roll", media = media, - selected = selected, - maxSelection = 32, + selectedMedia = selected, + canLongPress = true, showMultiSelectAction = false, onBack = {}, onStartMultiSelect = {}, From 0a94f6142d117c804fc7bd75f8c9e3cd5b1df3c2 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 8 Jan 2026 10:48:19 +0800 Subject: [PATCH 07/18] Dimensions file and compose cleanups --- .../securesms/mediasend/compose/Components.kt | 24 +++++++++---------- .../compose/MediaPickerFolderScreen.kt | 4 ++-- .../compose/MediaPickerItemScreen.kt | 7 +++--- .../securesms/ui/theme/Dimensions.kt | 7 +----- 4 files changed, 17 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt index a9b734bd4c..9773a70b7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt @@ -32,20 +32,18 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import coil3.compose.AsyncImage +import coil3.request.ImageRequest import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi -import com.bumptech.glide.integration.compose.GlideImage import network.loki.messenger.R import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.util.MediaUtil -import kotlin.collections.filterNot -import androidx.core.net.toUri -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import org.thoughtcrime.securesms.ui.theme.LocalDimensions -@OptIn(ExperimentalGlideComposeApi::class) @Composable fun MediaFolderCell( title: String, @@ -141,7 +139,7 @@ fun MediaPickerItemCell( .aspectRatio(1f) .border( width = LocalDimensions.current.borderStroke, - color = Color.White.copy(alpha = 0.20f) + color = LocalColors.current.borders.copy(alpha = 0.20f) ) .combinedClickable( onClick = { @@ -175,7 +173,7 @@ fun MediaPickerItemCell( Box( modifier = Modifier .align(Alignment.Center) - .size(LocalDimensions.current.mediaPlayOverlay) + .size(36.dp) .clip(CircleShape) .background(Color.White), contentAlignment = Alignment.Center @@ -207,7 +205,7 @@ fun MediaPickerItemCell( .padding(LocalDimensions.current.xxsSpacing), contentAlignment = Alignment.Center ) { - IndicatorOn(size = LocalDimensions.current.smallRadius) + IndicatorOn() Text( text = (selectedIndex + 1).toString(), @@ -223,7 +221,7 @@ fun MediaPickerItemCell( .align(Alignment.TopEnd) .padding(LocalDimensions.current.xxsSpacing) ) { - IndicatorOff(size = LocalDimensions.current.smallRadius) + IndicatorOff() } } } @@ -231,7 +229,7 @@ fun MediaPickerItemCell( } @Composable -private fun IndicatorOff(size: Dp, modifier: Modifier = Modifier) { +private fun IndicatorOff(modifier: Modifier = Modifier, size: Dp = 26.dp ) { Box( modifier = modifier .size(size) @@ -245,7 +243,7 @@ private fun IndicatorOff(size: Dp, modifier: Modifier = Modifier) { } @Composable -private fun IndicatorOn(size: Dp, modifier: Modifier = Modifier) { +private fun IndicatorOn(modifier: Modifier = Modifier, size: Dp = 26.dp) { Box( modifier = modifier .size(size) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt index e1722479f6..f05df8475f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt @@ -113,8 +113,8 @@ private fun MediaPickerFolder( modifier = Modifier .fillMaxSize() .background(LocalColors.current.background), - horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.mediaItemGridSpacing), - verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.mediaItemGridSpacing) + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.tinySpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.tinySpacing) ) { items( items = folders, diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt index e9b5a5e5a2..58af7e8b5a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -81,7 +80,7 @@ private fun MediaPickerItem( isMultiSelect: Boolean = false ) { - val itemWidth = LocalDimensions.current.mediaPickerItemWidth + val itemWidth = 85.dp val screenWidth = LocalConfiguration.current.screenWidthDp.dp val columns = maxOf(1, (screenWidth / itemWidth).toInt()) @@ -114,8 +113,8 @@ private fun MediaPickerItem( .padding(padding) .fillMaxSize() .background(LocalColors.current.background), - horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.mediaItemGridSpacing), - verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.mediaItemGridSpacing) + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.tinySpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.tinySpacing) ) { items(media, key = { it.uri }) { item -> val isSelected = selectedMedia.any { it.uri == item.uri } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt index acbb0c69a2..702391c596 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.unit.dp val LocalDimensions = staticCompositionLocalOf { Dimensions() } data class Dimensions( + val tinySpacing : Dp = 2.dp, val xxxsSpacing: Dp = 4.dp, val xxsSpacing: Dp = 8.dp, val xsSpacing: Dp = 12.dp, @@ -53,10 +54,4 @@ data class Dimensions( val maxContentSize: Dp = 520.dp, val minContentSizeMedium: Dp = 160.dp, val maxContentSizeMedium: Dp = 620.dp, - - val mediaPickerItemWidth : Dp = 85.dp, - val mediaItemGridSpacing : Dp = 2.dp, - val mediaPlayOverlay : Dp = 36.dp, - - val smallRadius : Dp = 26.dp ) From 794a101b34e7d571d1ab957d89590d5b4724d269 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 8 Jan 2026 11:40:30 +0800 Subject: [PATCH 08/18] initial update to checking permission --- .../v2/utilities/AttachmentManager.java | 13 ++++----- .../securesms/mediasend/MediaSendActivity.kt | 2 +- .../securesms/mediasend/MediaSendViewModel.kt | 12 ++++++++ .../MediaPickerFolderComposeFragment.kt | 20 +++++++++++-- .../compose/MediaPickerFolderScreen.kt | 28 +++++++------------ 5 files changed, 45 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java index 1abe337271..e776b735f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java @@ -24,7 +24,6 @@ import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; -import android.content.pm.PackageManager; import android.database.Cursor; import android.net.Uri; import android.os.AsyncTask; @@ -35,8 +34,6 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; import com.bumptech.glide.RequestManager; import com.squareup.phrase.Phrase; @@ -349,13 +346,13 @@ public static void selectGallery(Activity activity, int requestCode, @NonNull Ad .execute(); } - public static boolean hasFullAccess(Activity activity) { + public static boolean hasFullAccess(Context c) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return Permissions.hasAll(activity, + return Permissions.hasAll(c, Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO); } else { - return Permissions.hasAll(activity, android.Manifest.permission.READ_EXTERNAL_STORAGE); + return Permissions.hasAll(c, android.Manifest.permission.READ_EXTERNAL_STORAGE); } } @@ -386,9 +383,9 @@ public static void managePhotoAccess(@NonNull Activity activity, @Nullable Runna } } - public static boolean shouldShowManagePhoto(@NonNull Activity activity){ + public static boolean shouldShowManagePhoto(@NonNull Context c){ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE){ - return !hasFullAccess(activity) && hasPartialAccess(activity); + return !hasFullAccess(c) && hasPartialAccess(c); }else{ // No partial access for <= API 33 return false; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt index e37a8a784c..bd5ed3b00f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt @@ -55,7 +55,7 @@ import javax.inject.Inject * It will return the [Media] that the user decided to send. */ @AndroidEntryPoint -class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragment.Controller, +class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderComposeFragment.Controller, MediaPickerItemComposeFragment.Controller, MediaSendFragment.Controller, ImageEditorFragment.Controller { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt index c9ac68b8f4..89709a8b6e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.mediasend import android.app.Application import android.content.Context import android.net.Uri +import android.os.Build import androidx.lifecycle.LiveData import androidx.lifecycle.asLiveData import com.annimon.stream.Stream @@ -21,6 +22,8 @@ import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.InputbarViewModel +import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager.hasFullAccess +import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager.hasPartialAccess import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.pro.ProStatusManager @@ -337,6 +340,14 @@ class MediaSendViewModel @Inject constructor( lastImageCapture = Optional.absent() } + fun refreshPhotoAccessUi() { + val show = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && + !hasFullAccess(context) && + hasPartialAccess(context) + + _uiState.update { it.copy(showManagePhotoAccess = show) } + } + fun saveDrawState(state: Map) { savedDrawState.clear() savedDrawState.putAll(state) @@ -499,6 +510,7 @@ class MediaSendViewModel @Inject constructor( val position: Int = -1, val countVisibility: CountButtonState.Visibility = CountButtonState.Visibility.FORCED_OFF, val showCameraButton: Boolean = false, + val showManagePhotoAccess : Boolean = false ) { val count: Int get() = selectedMedia.size diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderComposeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderComposeFragment.kt index 637e1eaf65..c725605c1e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderComposeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderComposeFragment.kt @@ -16,6 +16,8 @@ import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.displayName +import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager +import org.thoughtcrime.securesms.mediasend.MediaFolder import org.thoughtcrime.securesms.mediasend.MediaPickerFolderFragment import org.thoughtcrime.securesms.mediasend.MediaSendViewModel import org.thoughtcrime.securesms.ui.setThemedContent @@ -26,11 +28,11 @@ class MediaPickerFolderComposeFragment : Fragment() { private val viewModel: MediaSendViewModel by activityViewModels() private var recipientName: String? = null - private var controller: MediaPickerFolderFragment.Controller? = null + private var controller: Controller? = null override fun onAttach(context: Context) { super.onAttach(context) - controller = activity as? MediaPickerFolderFragment.Controller + controller = activity as? Controller ?: throw IllegalStateException("Parent activity must implement controller class.") } @@ -42,6 +44,7 @@ class MediaPickerFolderComposeFragment : Fragment() { override fun onResume() { super.onResume() viewModel.onFolderPickerStarted() + viewModel.refreshPhotoAccessUi() } override fun onCreateView( @@ -68,12 +71,19 @@ class MediaPickerFolderComposeFragment : Fragment() { }, onFolderClick = { folder -> controller?.onFolderSelected(folder) - } + }, + manageMediaAccess = ::manageMediaAccess ) } } } + fun manageMediaAccess() { + AttachmentManager.managePhotoAccess(requireActivity()) { + viewModel.refreshFolders() + } + } + companion object { private const val KEY_RECIPIENT_NAME = "recipient_name" @@ -85,4 +95,8 @@ class MediaPickerFolderComposeFragment : Fragment() { } } } + + interface Controller { + fun onFolderSelected(folder: MediaFolder) + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt index f05df8475f..ec443c5028 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt @@ -21,17 +21,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.fragment.app.FragmentActivity import network.loki.messenger.R -import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager import org.thoughtcrime.securesms.mediasend.MediaFolder import org.thoughtcrime.securesms.mediasend.MediaSendViewModel import org.thoughtcrime.securesms.ui.components.BackAppBar @@ -43,7 +39,8 @@ fun MediaPickerFolderScreen( viewModel: MediaSendViewModel, onFolderClick: (MediaFolder) -> Unit, title: String, - handleBack: () -> Unit + handleBack: () -> Unit, + manageMediaAccess: () -> Unit ) { val uiState by viewModel.uiState.collectAsState() @@ -57,7 +54,8 @@ fun MediaPickerFolderScreen( onFolderClick = onFolderClick, title = title, handleBack = handleBack, - refreshFolders = { viewModel.refreshFolders() } + showManageMediaAccess = uiState.showManagePhotoAccess, + manageMediaAccess = manageMediaAccess ) } @@ -69,31 +67,24 @@ private fun MediaPickerFolder( onFolderClick: (folder: MediaFolder) -> Unit, title: String, handleBack: () -> Unit, - refreshFolders: () -> Unit + showManageMediaAccess: Boolean, + manageMediaAccess : () -> Unit ) { // span logic: screenWidth / media_picker_folder_width val folderWidth = dimensionResource(R.dimen.media_picker_folder_width) val columns = maxOf(1, (LocalConfiguration.current.screenWidthDp.dp / folderWidth).toInt()) - val context = LocalContext.current - val activity = context as? FragmentActivity - val showManage = remember(activity) { - activity?.let { AttachmentManager.shouldShowManagePhoto(it) } == true - } - Scaffold( topBar = { BackAppBar( title = title, onBack = handleBack, actions = { - if (showManage && activity != null) { + if (showManageMediaAccess) { IconButton( onClick = { - AttachmentManager.managePhotoAccess(activity) { - refreshFolders() - } + manageMediaAccess() } ) { Icon( @@ -159,6 +150,7 @@ private fun MediaPickerFolderPreview() { onFolderClick = {}, title = "Folders", handleBack = {}, - refreshFolders = {} + showManageMediaAccess = true, + manageMediaAccess = {} ) } \ No newline at end of file From f9fbcad0ee77b4e7c546341e63496307dd59b009 Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 9 Jan 2026 17:13:51 +0800 Subject: [PATCH 09/18] Updated paddings and color values --- .../securesms/mediasend/compose/Components.kt | 6 +-- .../compose/MediaPickerFolderScreen.kt | 1 + .../compose/MediaPickerItemScreen.kt | 47 ++++++++++--------- 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt index 9773a70b7d..69140a8fc1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt @@ -75,8 +75,8 @@ fun MediaFolderCell( Brush.verticalGradient( colorStops = arrayOf( 0.0f to Color.Transparent, - 0.5f to Color.Black.copy(alpha = 0.5f), - 1.0f to Color.Black.copy(alpha = 0.7f) + 0.5f to Color.Black.copy(alpha = 0.8f), + 1.0f to Color.Black.copy(alpha = 0.9f) ) ) ) @@ -139,7 +139,7 @@ fun MediaPickerItemCell( .aspectRatio(1f) .border( width = LocalDimensions.current.borderStroke, - color = LocalColors.current.borders.copy(alpha = 0.20f) + color = LocalColors.current.borders ) .combinedClickable( onClick = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt index ec443c5028..c7822323f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt @@ -102,6 +102,7 @@ private fun MediaPickerFolder( LazyVerticalGrid( columns = GridCells.Fixed(columns), modifier = Modifier + .padding(LocalDimensions.current.tinySpacing) .fillMaxSize() .background(LocalColors.current.background), horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.tinySpacing), diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt index 58af7e8b5a..ce09e89de9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.mediasend.compose import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells @@ -107,28 +108,30 @@ private fun MediaPickerItem( ) }, ) { padding -> - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - modifier = Modifier - .padding(padding) - .fillMaxSize() - .background(LocalColors.current.background), - horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.tinySpacing), - verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.tinySpacing) - ) { - items(media, key = { it.uri }) { item -> - val isSelected = selectedMedia.any { it.uri == item.uri } - MediaPickerItemCell( - media = item, - isSelected = isSelected, - selectedIndex = selectedMedia.indexOfFirst { it.uri == item.uri }, - isMultiSelect = isMultiSelect, - canLongPress = canLongPress, - showSelectionOn = isSelected, - onMediaChosen = { onSinglePick(it) }, - onSelectionStarted = onStartMultiSelect, - onSelectionChanged = onToggleSelection, - ) + Box(modifier = Modifier.padding(padding)) { + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = Modifier + .padding(LocalDimensions.current.tinySpacing) + .fillMaxSize() + .background(LocalColors.current.background), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.tinySpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.tinySpacing) + ) { + items(media, key = { it.uri }) { item -> + val isSelected = selectedMedia.any { it.uri == item.uri } + MediaPickerItemCell( + media = item, + isSelected = isSelected, + selectedIndex = selectedMedia.indexOfFirst { it.uri == item.uri }, + isMultiSelect = isMultiSelect, + canLongPress = canLongPress, + showSelectionOn = isSelected, + onMediaChosen = { onSinglePick(it) }, + onSelectionStarted = onStartMultiSelect, + onSelectionChanged = onToggleSelection, + ) + } } } } From e940360895f1b2c515698bafb825be54bbbd2cdd Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 12 Jan 2026 08:20:54 +0800 Subject: [PATCH 10/18] Removed old code, use innerShadow for Box --- .../mediasend/MediaPickerFolderAdapter.java | 98 --------- .../mediasend/MediaPickerFolderFragment.java | 199 ----------------- .../mediasend/MediaPickerItemAdapter.java | 174 --------------- .../mediasend/MediaPickerItemFragment.java | 205 ------------------ .../securesms/mediasend/compose/Components.kt | 21 +- .../MediaPickerFolderComposeFragment.kt | 1 - app/src/main/res/drawable/image_shade.xml | 12 - .../drawable/media_selected_indicator_off.xml | 6 - .../drawable/media_selected_indicator_on.xml | 5 - .../layout/mediapicker_folder_fragment.xml | 23 -- .../res/layout/mediapicker_folder_item.xml | 61 ------ .../res/layout/mediapicker_item_fragment.xml | 23 -- .../res/layout/mediapicker_media_item.xml | 91 -------- 13 files changed, 13 insertions(+), 906 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java delete mode 100644 app/src/main/res/drawable/image_shade.xml delete mode 100644 app/src/main/res/drawable/media_selected_indicator_off.xml delete mode 100644 app/src/main/res/drawable/media_selected_indicator_on.xml delete mode 100644 app/src/main/res/layout/mediapicker_folder_fragment.xml delete mode 100644 app/src/main/res/layout/mediapicker_folder_item.xml delete mode 100644 app/src/main/res/layout/mediapicker_item_fragment.xml delete mode 100644 app/src/main/res/layout/mediapicker_media_item.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java deleted file mode 100644 index a3afc9147a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java +++ /dev/null @@ -1,98 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; - - - -import network.loki.messenger.R; -import com.bumptech.glide.RequestManager; - -import java.util.ArrayList; -import java.util.List; - -@Deprecated -class MediaPickerFolderAdapter extends RecyclerView.Adapter { - - private final RequestManager glideRequests; - private final EventListener eventListener; - private final List folders; - - MediaPickerFolderAdapter(@NonNull RequestManager glideRequests, @NonNull EventListener eventListener) { - this.glideRequests = glideRequests; - this.eventListener = eventListener; - this.folders = new ArrayList<>(); - } - - @NonNull - @Override - public FolderViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { - return new FolderViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.mediapicker_folder_item, viewGroup, false)); - } - - @Override - public void onBindViewHolder(@NonNull FolderViewHolder folderViewHolder, int i) { - folderViewHolder.bind(folders.get(i), glideRequests, eventListener); - } - - @Override - public void onViewRecycled(@NonNull FolderViewHolder holder) { - holder.recycle(); - } - - @Override - public int getItemCount() { - return folders.size(); - } - - void setFolders(@NonNull List folders) { - this.folders.clear(); - this.folders.addAll(folders); - notifyDataSetChanged(); - } - - static class FolderViewHolder extends RecyclerView.ViewHolder { - - private final ImageView thumbnail; - private final ImageView icon; - private final TextView title; - private final TextView count; - - FolderViewHolder(@NonNull View itemView) { - super(itemView); - - thumbnail = itemView.findViewById(R.id.mediapicker_folder_item_thumbnail); - icon = itemView.findViewById(R.id.mediapicker_folder_item_icon); - title = itemView.findViewById(R.id.mediapicker_folder_item_title); - count = itemView.findViewById(R.id.mediapicker_folder_item_count); - } - - void bind(@NonNull MediaFolder folder, @NonNull RequestManager glideRequests, @NonNull EventListener eventListener) { - title.setText(folder.getTitle()); - count.setText(String.valueOf(folder.getItemCount())); - - glideRequests.load(folder.getThumbnailUri()) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .transition(DrawableTransitionOptions.withCrossFade()) - .into(thumbnail); - - itemView.setOnClickListener(v -> eventListener.onFolderClicked(folder)); - } - - void recycle() { - itemView.setOnClickListener(null); - } - } - - interface EventListener { - void onFolderClicked(@NonNull MediaFolder mediaFolder); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java deleted file mode 100644 index 321e3134a8..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java +++ /dev/null @@ -1,199 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import static org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY; - -import android.content.Context; -import android.content.res.Configuration; -import android.graphics.Point; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.Toolbar; -import androidx.core.view.MenuHost; -import androidx.core.view.MenuProvider; -import androidx.fragment.app.Fragment; -import androidx.lifecycle.Lifecycle; -import androidx.lifecycle.ViewModelProvider; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.bumptech.glide.Glide; -import com.squareup.phrase.Phrase; - -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsession.utilities.recipients.RecipientNamesKt; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager; -import org.thoughtcrime.securesms.util.ViewUtilitiesKt; - -import dagger.hilt.android.AndroidEntryPoint; -import network.loki.messenger.R; - -/** - * Allows the user to select a media folder to explore. - */ -@Deprecated -@AndroidEntryPoint -public class MediaPickerFolderFragment extends Fragment implements MediaPickerFolderAdapter.EventListener { - - private static final String KEY_RECIPIENT_NAME = "recipient_name"; - - private String recipientName; - private MediaSendViewModel viewModel; - private Controller controller; - private GridLayoutManager layoutManager; - - MediaPickerFolderAdapter adapter; - - private MenuProvider manageMenuProvider; - - public static @NonNull MediaPickerFolderFragment newInstance(@NonNull Recipient recipient) { - Bundle args = new Bundle(); - args.putString(KEY_RECIPIENT_NAME, RecipientNamesKt.displayName(recipient)); - - MediaPickerFolderFragment fragment = new MediaPickerFolderFragment(); - fragment.setArguments(args); - - return fragment; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - recipientName = getArguments().getString(KEY_RECIPIENT_NAME); - viewModel = new ViewModelProvider(requireActivity()).get(MediaSendViewModel.class); - } - - @Override - public void onAttach(Context context) { - super.onAttach(context); - - if (!(getActivity() instanceof Controller)) { - throw new IllegalStateException("Parent activity must implement controller class."); - } - - controller = (Controller) getActivity(); - } - - @Override - public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.mediapicker_folder_fragment, container, false); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - ViewUtilitiesKt.applySafeInsetsPaddings(view); - - RecyclerView list = view.findViewById(R.id.mediapicker_folder_list); - adapter = new MediaPickerFolderAdapter(Glide.with(this), this); - - layoutManager = new GridLayoutManager(requireContext(), 2); - onScreenWidthChanged(getScreenWidth()); - - list.setLayoutManager(layoutManager); - list.setAdapter(adapter); - - viewModel.getFolders().observe(getViewLifecycleOwner(), adapter::setFolders); - - initToolbar(view.findViewById(R.id.mediapicker_toolbar)); - } - - @Override - public void onResume() { - super.onResume(); - - viewModel.onFolderPickerStarted(); - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - onScreenWidthChanged(getScreenWidth()); - } - - private void initToolbar(Toolbar toolbar) { - ((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar); - ActionBar actionBar = ((AppCompatActivity) requireActivity()).getSupportActionBar(); - if (actionBar == null) { - Log.w("MediaPickerFolderFragment", "ActionBar is null in initToolbar - cannot continue."); - } else { - CharSequence txt = Phrase.from(requireContext(), R.string.attachmentsSendTo).put(NAME_KEY, recipientName).format(); - actionBar.setTitle(txt); - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setHomeButtonEnabled(true); - toolbar.setNavigationOnClickListener(v -> requireActivity().getOnBackPressedDispatcher().onBackPressed()); - } - - initToolbarOptions(); - } - - private void initToolbarOptions() { - MenuHost menuHost = (MenuHost) requireActivity(); - - // Always remove current provider first (if any) - if (manageMenuProvider != null) { - menuHost.removeMenuProvider(manageMenuProvider); - manageMenuProvider = null; - } - - if (AttachmentManager.shouldShowManagePhoto(requireActivity())) { - manageMenuProvider = new MenuProvider() { - @Override - public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { - inflater.inflate(R.menu.menu_media_add, menu); - } - - @Override - public boolean onMenuItemSelected(@NonNull MenuItem item) { - if (item.getItemId() == R.id.mediapicker_menu_add) { - AttachmentManager.managePhotoAccess(requireActivity(), () -> { - if (!isAdded()) return; - - viewModel.getFolders() - .observe(getViewLifecycleOwner(), adapter::setFolders); - - initToolbarOptions(); - }); - return true; - } - return false; - } - }; - - menuHost.addMenuProvider(manageMenuProvider, getViewLifecycleOwner(), Lifecycle.State.STARTED); - } - } - - private void onScreenWidthChanged(int newWidth) { - if (layoutManager != null) { - layoutManager.setSpanCount(newWidth / getResources().getDimensionPixelSize(R.dimen.media_picker_folder_width)); - } - } - - private int getScreenWidth() { - Point size = new Point(); - requireActivity().getWindowManager().getDefaultDisplay().getSize(size); - return size.x; - } - - @Override - public void onFolderClicked(@NonNull MediaFolder folder) { - controller.onFolderSelected(folder); - } - - @Deprecated - public interface Controller { - void onFolderSelected(@NonNull MediaFolder folder); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java deleted file mode 100644 index f7b2aba8ce..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java +++ /dev/null @@ -1,174 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; - -import network.loki.messenger.R; -import com.bumptech.glide.RequestManager; - -import org.thoughtcrime.securesms.util.MediaUtil; -import org.thoughtcrime.securesms.util.StableIdGenerator; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.LinkedList; -import java.util.List; - -@Deprecated -public class MediaPickerItemAdapter extends RecyclerView.Adapter { - - private final RequestManager glideRequests; - private final EventListener eventListener; - private final List media; - private final List selected; - private final int maxSelection; - private final StableIdGenerator stableIdGenerator; - - private boolean forcedMultiSelect; - - public MediaPickerItemAdapter(@NonNull RequestManager glideRequests, @NonNull EventListener eventListener, int maxSelection) { - this.glideRequests = glideRequests; - this.eventListener = eventListener; - this.media = new ArrayList<>(); - this.maxSelection = maxSelection; - this.stableIdGenerator = new StableIdGenerator<>(); - this.selected = new LinkedList<>(); - - setHasStableIds(true); - } - - @Override - public @NonNull ItemViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { - return new ItemViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.mediapicker_media_item, viewGroup, false)); - } - - @Override - public void onBindViewHolder(@NonNull ItemViewHolder holder, int i) { - holder.bind(media.get(i), forcedMultiSelect, selected, maxSelection, glideRequests, eventListener); - } - - @Override - public void onViewRecycled(@NonNull ItemViewHolder holder) { - holder.recycle(); - } - - @Override - public int getItemCount() { - return media.size(); - } - - @Override - public long getItemId(int position) { - return stableIdGenerator.getId(media.get(position)); - } - - void setMedia(@NonNull List media) { - this.media.clear(); - this.media.addAll(media); - notifyDataSetChanged(); - } - - void setSelected(@NonNull Collection selected) { - this.selected.clear(); - this.selected.addAll(selected); - notifyDataSetChanged(); - } - - List getSelected() { - return selected; - } - - void setForcedMultiSelect(boolean forcedMultiSelect) { - this.forcedMultiSelect = forcedMultiSelect; - notifyDataSetChanged(); - } - - static class ItemViewHolder extends RecyclerView.ViewHolder { - - private final ImageView thumbnail; - private final View playOverlay; - private final View selectOn; - private final View selectOff; - private final View selectOverlay; - private final TextView selectOrder; - - ItemViewHolder(@NonNull View itemView) { - super(itemView); - thumbnail = itemView.findViewById(R.id.mediapicker_image_item_thumbnail); - playOverlay = itemView.findViewById(R.id.mediapicker_play_overlay); - selectOn = itemView.findViewById(R.id.mediapicker_select_on); - selectOff = itemView.findViewById(R.id.mediapicker_select_off); - selectOverlay = itemView.findViewById(R.id.mediapicker_select_overlay); - selectOrder = itemView.findViewById(R.id.mediapicker_select_order); - } - - void bind(@NonNull Media media, boolean multiSelect, List selected, int maxSelection, @NonNull RequestManager glideRequests, @NonNull EventListener eventListener) { - glideRequests.load(media.getUri()) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .transition(DrawableTransitionOptions.withCrossFade()) - .into(thumbnail); - - playOverlay.setVisibility(MediaUtil.isVideoType(media.getMimeType()) ? View.VISIBLE : View.GONE); - - if (selected.isEmpty() && !multiSelect) { - itemView.setOnClickListener(v -> eventListener.onMediaChosen(media)); - selectOn.setVisibility(View.GONE); - selectOff.setVisibility(View.GONE); - selectOverlay.setVisibility(View.GONE); - - if (maxSelection > 1) { - itemView.setOnLongClickListener(v -> { - selected.add(media); - eventListener.onMediaSelectionStarted(); - eventListener.onMediaSelectionChanged(new ArrayList<>(selected)); - return true; - }); - } - } else if (selected.contains(media)) { - selectOff.setVisibility(View.VISIBLE); - selectOn.setVisibility(View.VISIBLE); - selectOverlay.setVisibility(View.VISIBLE); - selectOrder.setText(String.valueOf(selected.indexOf(media) + 1)); - itemView.setOnLongClickListener(null); - itemView.setOnClickListener(v -> { - selected.remove(media); - eventListener.onMediaSelectionChanged(new ArrayList<>(selected)); - }); - } else { - selectOff.setVisibility(View.VISIBLE); - selectOn.setVisibility(View.GONE); - selectOverlay.setVisibility(View.GONE); - itemView.setOnLongClickListener(null); - itemView.setOnClickListener(v -> { - if (selected.size() < maxSelection) { - selected.add(media); - eventListener.onMediaSelectionChanged(new ArrayList<>(selected)); - } else { - eventListener.onMediaSelectionOverflow(maxSelection); - } - }); - } - } - - void recycle() { - itemView.setOnClickListener(null); - } - - - } - - interface EventListener { - void onMediaChosen(@NonNull Media media); - void onMediaSelectionStarted(); - void onMediaSelectionChanged(@NonNull List media); - void onMediaSelectionOverflow(int maxSelection); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java deleted file mode 100644 index 065b4c6659..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java +++ /dev/null @@ -1,205 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import androidx.appcompat.app.ActionBar; -import androidx.lifecycle.ViewModelProvider; -import android.content.Context; -import android.content.res.Configuration; -import android.graphics.Point; -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.appcompat.app.AppCompatActivity; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.appcompat.widget.Toolbar; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowManager; -import android.widget.Toast; - -import com.bumptech.glide.Glide; -import org.session.libsession.utilities.Util; -import org.thoughtcrime.securesms.util.ViewUtilitiesKt; - -import java.util.ArrayList; -import java.util.List; - -import dagger.hilt.android.AndroidEntryPoint; -import network.loki.messenger.R; - -/** - * Allows the user to select a set of media items from a specified folder. - */ -@Deprecated -@AndroidEntryPoint -public class MediaPickerItemFragment extends Fragment implements MediaPickerItemAdapter.EventListener { - - private static final String KEY_BUCKET_ID = "bucket_id"; - private static final String KEY_FOLDER_TITLE = "folder_title"; - private static final String KEY_MAX_SELECTION = "max_selection"; - - private String bucketId; - private String folderTitle; - private int maxSelection; - private MediaSendViewModel viewModel; - private MediaPickerItemAdapter adapter; - private Controller controller; - private GridLayoutManager layoutManager; - - public static MediaPickerItemFragment newInstance(@NonNull String bucketId, @NonNull String folderTitle, int maxSelection) { - Bundle args = new Bundle(); - args.putString(KEY_BUCKET_ID, bucketId); - args.putString(KEY_FOLDER_TITLE, folderTitle); - args.putInt(KEY_MAX_SELECTION, maxSelection); - - MediaPickerItemFragment fragment = new MediaPickerItemFragment(); - fragment.setArguments(args); - - return fragment; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setHasOptionsMenu(true); - - bucketId = getArguments().getString(KEY_BUCKET_ID); - folderTitle = getArguments().getString(KEY_FOLDER_TITLE); - maxSelection = getArguments().getInt(KEY_MAX_SELECTION); - viewModel = new ViewModelProvider(requireActivity()).get(MediaSendViewModel.class); - } - - @Override - public void onAttach(Context context) { - super.onAttach(context); - - if (!(getActivity() instanceof Controller)) { - throw new IllegalStateException("Parent activity must implement controller class."); - } - - controller = (Controller) getActivity(); - } - - @Override - public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.mediapicker_item_fragment, container, false); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - ViewUtilitiesKt.applySafeInsetsPaddings(view); - - RecyclerView imageList = view.findViewById(R.id.mediapicker_item_list); - - adapter = new MediaPickerItemAdapter(Glide.with(this), this, maxSelection); - layoutManager = new GridLayoutManager(requireContext(), 4); - - imageList.setLayoutManager(layoutManager); - imageList.setAdapter(adapter); - - initToolbar(view.findViewById(R.id.mediapicker_toolbar)); - onScreenWidthChanged(getScreenWidth()); - - if (!Util.isEmpty(viewModel.getSelectedMedia().getValue())) { - adapter.setSelected(viewModel.getSelectedMedia().getValue()); - onMediaSelectionChanged(new ArrayList<>(viewModel.getSelectedMedia().getValue())); - } - - viewModel.getMediaInBucket(bucketId).observe(getViewLifecycleOwner(), adapter::setMedia); - - initMediaObserver(viewModel); - } - - @Override - public void onResume() { - super.onResume(); - - viewModel.onItemPickerStarted(); - } - - @Override - public void onPrepareOptionsMenu(Menu menu) { - requireActivity().getMenuInflater().inflate(R.menu.mediapicker_default, menu); - - if (viewModel.getCountButtonState().getValue() != null && viewModel.getCountButtonState().getValue().isVisible()) { - menu.findItem(R.id.mediapicker_menu_add).setVisible(false); - } - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.mediapicker_menu_add: - adapter.setForcedMultiSelect(true); - viewModel.onMultiSelectStarted(); - return true; - } - return false; - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - onScreenWidthChanged(getScreenWidth()); - } - - @Override - public void onMediaChosen(@NonNull Media media) { - controller.onMediaSelected(media); - } - - @Override - public void onMediaSelectionStarted() { - viewModel.onMultiSelectStarted(); - } - - @Override - public void onMediaSelectionChanged(@NonNull List selected) { - adapter.notifyDataSetChanged(); - viewModel.onSelectedMediaChanged(selected); - } - - @Override - public void onMediaSelectionOverflow(int maxSelection) { - Toast.makeText(requireContext(), getString(R.string.attachmentsErrorNumber), Toast.LENGTH_SHORT).show(); - } - - private void initToolbar(Toolbar toolbar) { - AppCompatActivity activity = (AppCompatActivity) requireActivity(); - activity.setSupportActionBar(toolbar); - ActionBar actionBar = activity.getSupportActionBar(); - actionBar.setTitle(folderTitle); - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setHomeButtonEnabled(true); - - toolbar.setNavigationOnClickListener(v -> requireActivity().getOnBackPressedDispatcher().onBackPressed()); - } - - private void initMediaObserver(@NonNull MediaSendViewModel viewModel) { - viewModel.getCountButtonState().observe(getViewLifecycleOwner(), media -> { - requireActivity().invalidateOptionsMenu(); - }); - } - - private void onScreenWidthChanged(int newWidth) { - if (layoutManager != null) { - layoutManager.setSpanCount(newWidth / getResources().getDimensionPixelSize(R.dimen.media_picker_item_width)); - } - } - - private int getScreenWidth() { - Point size = new Point(); - requireActivity().getWindowManager().getDefaultDisplay().getSize(size); - return size.x; - } - - public interface Controller { - void onMediaSelected(@NonNull Media media); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt index 69140a8fc1..e45933737b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt @@ -16,15 +16,19 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.innerShadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -32,6 +36,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.core.net.toUri import coil3.compose.AsyncImage @@ -71,13 +76,13 @@ fun MediaFolderCell( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() - .background( - Brush.verticalGradient( - colorStops = arrayOf( - 0.0f to Color.Transparent, - 0.5f to Color.Black.copy(alpha = 0.8f), - 1.0f to Color.Black.copy(alpha = 0.9f) - ) + .background( Color.Transparent) + .innerShadow( + shape = RectangleShape, + shadow = Shadow( + radius = 8.dp, + color = Color.Black.copy(alpha = 0.4f), + offset = DpOffset(x = (-2).dp, (-40).dp) // shadow appears form the bottom ) ) .padding(LocalDimensions.current.smallSpacing) @@ -209,7 +214,7 @@ fun MediaPickerItemCell( Text( text = (selectedIndex + 1).toString(), - color = LocalColors.current.onInvertedBackgroundAccent, + color = LocalColors.current.text, style = LocalType.current.base, textAlign = TextAlign.Center ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderComposeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderComposeFragment.kt index c725605c1e..9c8c9667a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderComposeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderComposeFragment.kt @@ -18,7 +18,6 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.displayName import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager import org.thoughtcrime.securesms.mediasend.MediaFolder -import org.thoughtcrime.securesms.mediasend.MediaPickerFolderFragment import org.thoughtcrime.securesms.mediasend.MediaSendViewModel import org.thoughtcrime.securesms.ui.setThemedContent diff --git a/app/src/main/res/drawable/image_shade.xml b/app/src/main/res/drawable/image_shade.xml deleted file mode 100644 index e7616a18c6..0000000000 --- a/app/src/main/res/drawable/image_shade.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/media_selected_indicator_off.xml b/app/src/main/res/drawable/media_selected_indicator_off.xml deleted file mode 100644 index 3bb5a47aa1..0000000000 --- a/app/src/main/res/drawable/media_selected_indicator_off.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/media_selected_indicator_on.xml b/app/src/main/res/drawable/media_selected_indicator_on.xml deleted file mode 100644 index 002385210b..0000000000 --- a/app/src/main/res/drawable/media_selected_indicator_on.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/mediapicker_folder_fragment.xml b/app/src/main/res/layout/mediapicker_folder_fragment.xml deleted file mode 100644 index ac676085aa..0000000000 --- a/app/src/main/res/layout/mediapicker_folder_fragment.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/mediapicker_folder_item.xml b/app/src/main/res/layout/mediapicker_folder_item.xml deleted file mode 100644 index a7b1547a7d..0000000000 --- a/app/src/main/res/layout/mediapicker_folder_item.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/mediapicker_item_fragment.xml b/app/src/main/res/layout/mediapicker_item_fragment.xml deleted file mode 100644 index 39543eb987..0000000000 --- a/app/src/main/res/layout/mediapicker_item_fragment.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/mediapicker_media_item.xml b/app/src/main/res/layout/mediapicker_media_item.xml deleted file mode 100644 index 13d69b2870..0000000000 --- a/app/src/main/res/layout/mediapicker_media_item.xml +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file From 42d5019aff5bbe5ff0d67d457e508ccc9504b591 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 12 Jan 2026 08:26:09 +0800 Subject: [PATCH 11/18] Selection index text set to white --- .../org/thoughtcrime/securesms/mediasend/compose/Components.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt index e45933737b..db2ffa0327 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt @@ -214,7 +214,7 @@ fun MediaPickerItemCell( Text( text = (selectedIndex + 1).toString(), - color = LocalColors.current.text, + color = Color.White, style = LocalType.current.base, textAlign = TextAlign.Center ) From 135a06d5fd65bbd3232f2f7bacc56aff43da79f3 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 12 Jan 2026 09:18:28 +0800 Subject: [PATCH 12/18] Removed unused dimensions, updated padding for Grid --- .../org/thoughtcrime/securesms/mediasend/compose/Components.kt | 1 - .../securesms/mediasend/compose/MediaPickerFolderScreen.kt | 2 +- .../securesms/mediasend/compose/MediaPickerItemScreen.kt | 3 +-- app/src/main/res/values/dimens.xml | 3 --- 4 files changed, 2 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt index db2ffa0327..9866b9a420 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt @@ -76,7 +76,6 @@ fun MediaFolderCell( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() - .background( Color.Transparent) .innerShadow( shape = RectangleShape, shadow = Shadow( diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt index c7822323f1..9e35b3ec24 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt @@ -72,7 +72,7 @@ private fun MediaPickerFolder( ) { // span logic: screenWidth / media_picker_folder_width - val folderWidth = dimensionResource(R.dimen.media_picker_folder_width) + val folderWidth = 175.dp val columns = maxOf(1, (LocalConfiguration.current.screenWidthDp.dp / folderWidth).toInt()) Scaffold( diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt index ce09e89de9..746af8ff64 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt @@ -108,10 +108,10 @@ private fun MediaPickerItem( ) }, ) { padding -> - Box(modifier = Modifier.padding(padding)) { LazyVerticalGrid( columns = GridCells.Fixed(columns), modifier = Modifier + .padding(padding) .padding(LocalDimensions.current.tinySpacing) .fillMaxSize() .background(LocalColors.current.background), @@ -133,7 +133,6 @@ private fun MediaPickerItem( ) } } - } } } diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index e29c6d5ecb..1e1bba5ccb 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -63,9 +63,6 @@ 210dp 150dp - 175dp - 85dp - 4 10dp From 1cdaed58ccdc56d3f3347b3aac4aa871e19b5341 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 12 Jan 2026 09:24:29 +0800 Subject: [PATCH 13/18] Cleanup --- .../securesms/mediasend/compose/MediaPickerFolderScreen.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt index 9e35b3ec24..0e7be107b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp From f13838ff384e88db9c489abe2840d053f111e98e Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 12 Jan 2026 14:43:36 +1100 Subject: [PATCH 14/18] UI tweaks --- .../securesms/mediasend/compose/Components.kt | 159 +++++++++--------- .../prosettings/chooseplan/ChoosePlan.kt | 4 +- .../securesms/ui/components/ButtonType.kt | 10 +- .../securesms/ui/theme/ThemeColors.kt | 10 +- 4 files changed, 95 insertions(+), 88 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt index 9866b9a420..72449650d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt @@ -1,6 +1,13 @@ package org.thoughtcrime.securesms.mediasend.compose import android.net.Uri +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.Crossfade +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -16,7 +23,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width 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 @@ -24,7 +30,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.innerShadow -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape @@ -44,6 +49,7 @@ import coil3.request.ImageRequest import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import network.loki.messenger.R import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.ui.AnimateFade import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -59,65 +65,65 @@ fun MediaFolderCell( Box( modifier = Modifier .fillMaxWidth() + .aspectRatio(1f) .clickable(onClick = onClick) ) { - Box(modifier = Modifier.aspectRatio(1f)) { - AsyncImage( - modifier = Modifier.fillMaxWidth(), - contentScale = ContentScale.Crop, - model = ImageRequest.Builder(LocalContext.current) - .data(thumbnailUri) - .build(), - contentDescription = null, - ) + AsyncImage( + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Crop, + model = ImageRequest.Builder(LocalContext.current) + .data(thumbnailUri) + .build(), + contentDescription = null, + ) - // Bottom shade overlay - Box( + // Bottom row + Box( + modifier = Modifier.fillMaxSize() + .innerShadow( + shape = RectangleShape, + shadow = Shadow( + radius = 8.dp, + color = Color.Black.copy(alpha = 0.4f), + offset = DpOffset(x = 0.dp, (-40).dp) // shadow appears form the bottom + ) + ) + ) { + Row( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() - .innerShadow( - shape = RectangleShape, - shadow = Shadow( - radius = 8.dp, - color = Color.Black.copy(alpha = 0.4f), - offset = DpOffset(x = (-2).dp, (-40).dp) // shadow appears form the bottom - ) - ) - .padding(LocalDimensions.current.smallSpacing) + .padding( + horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xxsSpacing + ), + verticalAlignment = Alignment.CenterVertically ) { - // Bottom row - Row( - modifier = Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Image( - painter = painterResource(R.drawable.ic_baseline_folder_24), - contentDescription = null, - modifier = Modifier.size(LocalDimensions.current.iconSmall), - colorFilter = ColorFilter.tint(Color.White) - ) + Image( + painter = painterResource(R.drawable.ic_baseline_folder_24), + contentDescription = null, + modifier = Modifier.size(LocalDimensions.current.iconSmall), + colorFilter = ColorFilter.tint(Color.White) + ) - Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) + Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) - Text( - text = title, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = Color.White, - style = MaterialTheme.typography.bodyMedium - ) + Text( + text = title, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) - Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) + Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) - Text( - text = count.toString(), - color = Color.White, - style = MaterialTheme.typography.bodyMedium - ) - } + Text( + text = count.toString(), + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) } } } @@ -141,10 +147,6 @@ fun MediaPickerItemCell( Box( modifier = modifier .aspectRatio(1f) - .border( - width = LocalDimensions.current.borderStroke, - color = LocalColors.current.borders - ) .combinedClickable( onClick = { if (!isMultiSelect) { @@ -192,7 +194,7 @@ fun MediaPickerItemCell( } // Selection overlay - if (isSelected) { + AnimateFade(isSelected, modifier = Modifier.matchParentSize()) { Box( Modifier .matchParentSize() @@ -200,38 +202,43 @@ fun MediaPickerItemCell( ) } - if (isMultiSelect) { - // Select ON badge + order number (top-end) - if (showSelectionOn) { - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(LocalDimensions.current.xxsSpacing), - contentAlignment = Alignment.Center - ) { - IndicatorOn() + val state: BadgeState = + when { + !isMultiSelect -> BadgeState.Hidden + selectedIndex < 0 -> BadgeState.Off + else -> BadgeState.On(selectedIndex + 1) + } + + Crossfade( + targetState = state, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(LocalDimensions.current.xxsSpacing), + ) { s -> + when (s) { + BadgeState.Hidden -> Unit + BadgeState.Off -> IndicatorOff() + is BadgeState.On -> Box(contentAlignment = Alignment.Center) { + IndicatorOn() Text( - text = (selectedIndex + 1).toString(), - color = Color.White, + text = s.number.toString(), + color = LocalColors.current.textOnAccent, style = LocalType.current.base, textAlign = TextAlign.Center ) } - } else { - // Select OFF badge - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(LocalDimensions.current.xxsSpacing) - ) { - IndicatorOff() - } } } } } +private sealed interface BadgeState { + data object Hidden : BadgeState + data object Off : BadgeState + data class On(val number: Int) : BadgeState +} + @Composable private fun IndicatorOff(modifier: Modifier = Modifier, size: Dp = 26.dp ) { Box( diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt index a18972c7c5..b26569fb70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt @@ -377,7 +377,7 @@ private fun PlanBadge( maxLines = 1, overflow = TextOverflow.Ellipsis, style = LocalType.current.small.bold().copy( - color = LocalColors.current.accentButtonFillText + color = LocalColors.current.textOnAccent ) ) @@ -392,7 +392,7 @@ private fun PlanBadge( Image( painter = painterResource(id = R.drawable.ic_circle_help), contentDescription = null, - colorFilter = ColorFilter.tint(LocalColors.current.accentButtonFillText), + colorFilter = ColorFilter.tint(LocalColors.current.textOnAccent), modifier = Modifier .size(LocalDimensions.current.iconXXSmall) .clickable { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt index 9c359cf32e..134eaf19e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt @@ -52,7 +52,7 @@ interface ButtonType { override fun buttonColors() = ButtonDefaults.buttonColors( contentColor = LocalColors.current.background, containerColor = containerColor, - disabledContentColor = LocalColors.current.accentButtonFillText, + disabledContentColor = LocalColors.current.textOnAccent, disabledContainerColor = LocalColors.current.disabled ) } @@ -62,9 +62,9 @@ interface ButtonType { override fun border(enabled: Boolean) = null @Composable override fun buttonColors() = ButtonDefaults.buttonColors( - contentColor = LocalColors.current.accentButtonFillText, + contentColor = LocalColors.current.textOnAccent, containerColor = LocalColors.current.accent, - disabledContentColor = LocalColors.current.accentButtonFillText, + disabledContentColor = LocalColors.current.textOnAccent, disabledContainerColor = LocalColors.current.disabled ) } @@ -76,7 +76,7 @@ interface ButtonType { override fun buttonColors() = ButtonDefaults.buttonColors( contentColor = LocalColors.current.text, containerColor = LocalColors.current.backgroundTertiary, - disabledContentColor = LocalColors.current.accentButtonFillText, + disabledContentColor = LocalColors.current.textOnAccent, disabledContainerColor = LocalColors.current.disabled ) } @@ -88,7 +88,7 @@ interface ButtonType { override fun buttonColors() = ButtonDefaults.buttonColors( contentColor = Color.Black, containerColor = dangerDark, - disabledContentColor = LocalColors.current.accentButtonFillText, + disabledContentColor = LocalColors.current.textOnAccent, disabledContainerColor = LocalColors.current.disabled ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt index e2fd221faa..9dcb82667d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt @@ -34,7 +34,7 @@ interface ThemeColors { val textBubbleReceived: Color val qrCodeContent: Color val qrCodeBackground: Color - val accentButtonFillText: Color + val textOnAccent: Color val accentText: Color } @@ -127,7 +127,7 @@ data class ClassicDark(override val accent: Color = primaryGreen) : ThemeColors override val textBubbleReceived = Color.White override val qrCodeContent = background override val qrCodeBackground = text - override val accentButtonFillText = Color.Black + override val textOnAccent = Color.Black override val accentText = accent override val textAlert: Color = classicDark0 } @@ -149,7 +149,7 @@ data class ClassicLight(override val accent: Color = primaryGreen) : ThemeColors override val textBubbleReceived = classicLight4 override val qrCodeContent = text override val qrCodeBackground = backgroundSecondary - override val accentButtonFillText = Color.Black + override val textOnAccent = Color.Black override val accentText = text override val textAlert: Color = classicLight0 } @@ -171,7 +171,7 @@ data class OceanDark(override val accent: Color = primaryBlue) : ThemeColors { override val textBubbleReceived = oceanDark4 override val qrCodeContent = background override val qrCodeBackground = text - override val accentButtonFillText = Color.Black + override val textOnAccent = Color.Black override val accentText = accent override val textAlert: Color = oceanDark0 } @@ -193,7 +193,7 @@ data class OceanLight(override val accent: Color = primaryBlue) : ThemeColors { override val textBubbleReceived = oceanLight1 override val qrCodeContent = text override val qrCodeBackground = backgroundSecondary - override val accentButtonFillText = Color.Black + override val textOnAccent = Color.Black override val accentText = text override val textAlert: Color = oceanLight0 } From 590cae738d3f8afea8942748b7acb06d832145ef Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 2 Feb 2026 17:54:29 +0800 Subject: [PATCH 15/18] SES-4433 : Handle landscape mode (#1527) * removed portrait lock for most activities, retained for WebRtcCallActivity * Initial landscape, compact, and flip compatibility * Landing screen initial landscape compat with flip, tablet and phone * QR code activity landscape * Initial layout for Start Conversation sheet * Added Adaptive layout helper * Revert "Initial landscape, compact, and flip compatibility" This reverts commit 20a5fa9862a09f4be5763ac824d6a68cb2b39dde. * Use adaptive layout * made sholdUseTwoPane private * removed padding for back button * GiphyActivity initial compose * GiphyFragment compose * GiphyFragment initial kotlin * cleanup * GiphyFragment to kotlin * Cleanup * Converted async task to coroutine * Giphy fragment and sticket to kotlin * Separate compose fun * Start conversation cleanup * cleanup * Cleanup * notif landscape fix * Revert "notif landscape fix" This reverts commit 3cc3ec039af9a95d17dc77b91262f574398d799a. * initial scaling for QR in recovery password * Updated usage of twopane into landscape check only. * Share component for qrPanel * Fixed overlap for 3 button navigation * camera inset in conversation * apply safe inset camera inset * remove portrait mode for webrtccall * cleanup * Media preview insets * Use state for giph loading * Use state for giphyLoading * themed content for tabs * Removed two pane * GiphyActivity converted to kotlin, updated compose usage * Cleanup * A bit of optimization for session netowork's node image * inset for settings screen * token page and conversation settings * App bar search bar insets * WebRtc rotation fix, Landscape xml * contactAvatar webRtc adaptive * refactor adaptiveInfo name * State list drawable landscape * rtc call landscape update * Added insets and updated overlay stub * Serializable data for bottom sheet. Fixed crashes * Fixed landscape scroll for conversation bottomsheet * insets for cameraXfragment * Initial workaround for giphytabs * cleanup * Let activity handle the orientation change * Fixed how web rtc looks on landscape * allow recreation of layout for fragment. Add saveInstance check to prevent duplicate framgnet * Simplified logic * Added to LocalDimensions * cleanups * Filter thread record instead of db call * Cleanup * Removed auto scroll flag from setDeviceOrientation function * Search contact action bottomsheet landscape state expanded * convert calculation into anchor * Narrow width use anchor, helper function to get anchor * Safe insets for start conversation sheet * Dynamic insets if sheet does not fill the width * Update minimum size, fixed box constraints * cleanup * draw on image fix layout * spacing fixes for editing images * fixed constraints for color slider * initial configuration change handling * Revert "initial configuration change handling" This reverts commit 8c9891d00617cf0a41843aca3a5444fe7816fb83. * Made some QR areas scrollable but fit area * conversationv2 landscape layout * Remove landscape hiding the rail * Refactored dimen name * added comments * Fixed rotation value * Removed landscape layout, added rotation to back arrow * Handle onConfigureChanged for WebrtcActivity * Cleanups * Updated inset margine helper to fix home FAB * Initial orientation handling for CameraXFragment * Added CameraXActivity to replace old fragment * Fixed insets for CameraXActivity * Added tiny helper for bottom margin insets * Fixed missing dimen * SDK 36 bump * Updated back handling for activity with fragment * Updated back handling * Video camera landscape adjustment * Updated avatar sizes for portrait and landscape * Navigator to survive config changes, collapsible footer inset * replaced vm with retain * Used retain for navigations, updated insets for debug and pro screens * Fixed leftoever condition * Updated landscape screen for manage group stuff * ManageMembersScreen cleanup and improvements * More cleanups * Updated some remembers to retain * Minor cleanup --------- Co-authored-by: ThomasSession --- app/src/main/AndroidManifest.xml | 59 +- .../securesms/BaseActionBarActivity.kt | 6 +- .../securesms/MediaPreviewActivity.kt | 23 +- .../conversation/v2/ConversationActivityV2.kt | 8 + .../v2/ConversationReactionOverlay.kt | 73 ++- .../conversation/v2/ConversationV2Dialogs.kt | 4 +- .../settings/ConversationSettingsDialogs.kt | 3 +- .../settings/ConversationSettingsNavHost.kt | 21 +- .../v2/settings/ConversationSettingsScreen.kt | 3 +- .../securesms/debugmenu/DebugMenu.kt | 35 +- .../securesms/debugmenu/DebugMenuNavHost.kt | 3 +- .../securesms/giph/ui/GiphyActivity.java | 157 ------ .../securesms/giph/ui/GiphyActivity.kt | 158 ++++++ .../securesms/giph/ui/GiphyAdapter.java | 4 +- .../securesms/giph/ui/GiphyFragment.java | 148 ----- .../securesms/giph/ui/GiphyFragment.kt | 163 ++++++ .../securesms/giph/ui/GiphyGifFragment.java | 20 - .../securesms/giph/ui/GiphyGifFragment.kt | 21 + .../giph/ui/GiphyStickerFragment.java | 18 - .../securesms/giph/ui/GiphyStickerFragment.kt | 22 + .../giph/ui/compose/GiphyFragmentCompose.kt | 68 +++ .../giph/ui/compose/GiphyTabsCompose.kt | 41 ++ .../securesms/groups/compose/Components.kt | 121 +++- .../groups/compose/InviteAccountIdScreen.kt | 1 - .../groups/compose/InviteContactsScreen.kt | 100 ++-- .../groups/compose/ManageGroupAdminsScreen.kt | 230 ++++---- .../compose/ManageGroupMembersScreen.kt | 229 ++++---- .../groups/compose/PromoteMembersScreen.kt | 101 ++-- .../home/ConversationOptionsBottomSheet.kt | 73 ++- .../securesms/home/ConversationView.kt | 2 +- .../securesms/home/HomeActivity.kt | 154 +++--- .../securesms/home/HomeDialogs.kt | 20 +- .../securesms/home/HomeViewModel.kt | 2 + .../search/SearchContactActionBottomSheet.kt | 28 +- .../StartConversationSheet.kt | 31 +- .../home/StartConversation.kt | 279 ++++++---- .../securesms/media/MediaOverviewScreen.kt | 11 +- .../securesms/mediasend/CameraXActivity.kt | 366 ++++++++++++ .../securesms/mediasend/CameraXFragment.kt | 278 ---------- .../mediasend/MediaPickerFolderFragment.java | 2 +- .../mediasend/MediaPickerItemFragment.java | 2 +- .../securesms/mediasend/MediaSendActivity.kt | 248 +++++---- .../migration/DatabaseMigrationScreen.kt | 10 +- .../securesms/onboarding/landing/Landing.kt | 5 +- .../MessageNotificationsActivity.kt | 16 +- .../pickname/PickDisplayNameActivity.kt | 18 +- .../securesms/preferences/QRCodeActivity.kt | 88 ++- .../securesms/preferences/SettingsScreen.kt | 8 +- .../prosettings/BaseProSettingsScreens.kt | 24 +- .../prosettings/ProSettingsNavHost.kt | 3 +- .../recoverypassword/RecoveryPassword.kt | 47 +- .../scribbles/StickerSelectActivity.java | 5 +- .../securesms/tokenpage/TokenPage.kt | 85 ++- .../thoughtcrime/securesms/ui/Components.kt | 8 +- .../thoughtcrime/securesms/ui/Modifiers.kt | 5 - .../securesms/ui/adaptive/AdaptiveLayout.kt | 30 + .../ui/components/ConversationAppBar.kt | 36 +- .../securesms/ui/components/DropDown.kt | 3 +- .../securesms/ui/components/QR.kt | 3 +- .../securesms/ui/components/SessionTabRow.kt | 69 ++- .../securesms/ui/theme/Dimensions.kt | 7 + .../securesms/util/ViewUtilities.kt | 33 +- .../securesms/webrtc/CallManager.kt | 4 +- .../securesms/webrtc/CallViewModel.kt | 12 +- .../securesms/webrtc/OrientationManager.kt | 8 +- .../securesms/webrtc/WebRtcCallActivity.kt | 148 +++-- .../main/res/layout-land/activity_camerax.xml | 72 +++ .../layout-land/activity_conversation_v2.xml | 331 +++++++++++ .../main/res/layout-land/activity_webrtc.xml | 282 ++++++++++ .../main/res/layout-land/image_editor_hud.xml | 183 ++++++ app/src/main/res/layout/activity_camerax.xml | 70 +++ .../res/layout/activity_camerax_landscape.xml | 71 +++ .../res/layout/activity_camerax_portrait.xml | 69 +++ .../res/layout/activity_conversation_v2.xml | 520 +++++++++--------- app/src/main/res/layout/activity_webrtc.xml | 7 +- .../activity_webrtc_landscape_template.xml | 282 ++++++++++ .../activity_webrtc_portrait_template.xml | 279 ++++++++++ app/src/main/res/layout/camerax_fragment.xml | 65 --- .../fragment_conversation_bottom_sheet.xml | 191 ++++--- app/src/main/res/layout/giphy_activity.xml | 11 +- app/src/main/res/layout/giphy_fragment.xml | 28 +- app/src/main/res/layout/image_editor_hud.xml | 83 ++- .../res/layout/media_preview_activity.xml | 2 +- .../main/res/layout/mediasend_activity.xml | 46 +- .../main/res/layout/mediasend_fragment.xml | 1 + .../session_logo_action_bar_content.xml | 2 +- app/src/main/res/layout/view_conversation.xml | 3 +- .../main/res/layout/view_mediasend_count.xml | 44 ++ app/src/main/res/values/dimens.xml | 1 + gradle/libs.versions.toml | 4 +- 90 files changed, 4589 insertions(+), 2069 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyGifFragment.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyGifFragment.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyStickerFragment.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyStickerFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/giph/ui/compose/GiphyFragmentCompose.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/giph/ui/compose/GiphyTabsCompose.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXActivity.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/ui/adaptive/AdaptiveLayout.kt create mode 100644 app/src/main/res/layout-land/activity_camerax.xml create mode 100644 app/src/main/res/layout-land/activity_conversation_v2.xml create mode 100644 app/src/main/res/layout-land/activity_webrtc.xml create mode 100644 app/src/main/res/layout-land/image_editor_hud.xml create mode 100644 app/src/main/res/layout/activity_camerax.xml create mode 100644 app/src/main/res/layout/activity_camerax_landscape.xml create mode 100644 app/src/main/res/layout/activity_camerax_portrait.xml create mode 100644 app/src/main/res/layout/activity_webrtc_landscape_template.xml create mode 100644 app/src/main/res/layout/activity_webrtc_portrait_template.xml delete mode 100644 app/src/main/res/layout/camerax_fragment.xml create mode 100644 app/src/main/res/layout/view_mediasend_count.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3babe8fe18..ce614343c5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -104,41 +104,33 @@ + android:label="@string/sessionMessageRequests"/> + android:name="org.thoughtcrime.securesms.home.PathActivity"/> + android:label="@string/sessionSettings" /> + android:theme="@style/Theme.Session.DayNight.NoActionBar" /> + android:name="org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity" /> + android:label="@string/sessionPrivacy" /> + android:name="org.thoughtcrime.securesms.preferences.NotificationSettingsActivity" /> + android:name="org.thoughtcrime.securesms.preferences.ChatSettingsActivity" /> - + android:label="@string/sessionHelp"/> + @@ -264,7 +240,6 @@ + android:windowSoftInputMode="stateHidden"/> + + android:theme="@style/Theme.Session.DayNight.NoActionBar" + android:configChanges="orientation|screenSize|keyboardHidden|layoutDirection"> diff --git a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt index 2a880b273f..4bf93fd13d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt @@ -147,11 +147,11 @@ abstract class BaseActionBarActivity : AppCompatActivity() { } override fun onSupportNavigateUp(): Boolean { - if (super.onSupportNavigateUp()) return true - - onBackPressed() + if (handleNavigateUp()) return true + onBackPressedDispatcher.onBackPressed() return true } + protected open fun handleNavigateUp(): Boolean = false private fun initializeScreenshotSecurity(isResume: Boolean) { if (!isResume) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt index 86cc81efba..56f9af9f8b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt @@ -27,6 +27,7 @@ import android.net.Uri import android.os.AsyncTask import android.os.Build import android.os.Bundle +import android.util.AttributeSet import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem @@ -100,6 +101,7 @@ import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.FilenameUtils.getFilenameFromUri import org.thoughtcrime.securesms.util.SaveAttachmentTask import org.thoughtcrime.securesms.util.SaveAttachmentTask.Companion.showOneTimeWarningDialogOrSave +import org.thoughtcrime.securesms.util.applySafeInsetsPaddings import java.io.IOException import java.util.WeakHashMap import javax.inject.Inject @@ -175,8 +177,12 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), ViewCompat.setOnApplyWindowInsetsListener(findViewById(android.R.id.content)) { view, windowInsets -> val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()) windowInsetBottom = insets.bottom - - binding.toolbar.updatePadding(top = insets.top) + + binding.toolbar.updatePadding( + left = insets.left, + top = insets.top, + right = insets.right + ) binding.mediaPreviewAlbumRailContainer.updatePadding(bottom = insets.bottom) updateControlsPosition() @@ -261,9 +267,6 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), } private fun showAlbumRail() { - // never show the rail in landscape - if(isLandscape()) return - val rail = binding.mediaPreviewAlbumRailContainer rail.animate().cancel() rail.visibility = View.VISIBLE @@ -392,13 +395,11 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - // always hide the rail in landscape - if (isLandscape()) { - hideAlbumRail() + + if (!isFullscreen) { + showAlbumRail() } else { - if (!isFullscreen) { - showAlbumRail() - } + hideAlbumRail() } // Re-apply fullscreen if we were already in it diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index d84797b7b4..d001fd95c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -42,6 +42,7 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding import androidx.fragment.app.DialogFragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.LiveData @@ -714,6 +715,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, val navInsets = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()) val imeInsets = windowInsets.getInsets(WindowInsetsCompat.Type.ime()) + val systemBarsInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()) + val keyboardVisible = imeInsets.bottom > 0 if (keyboardVisible != isKeyboardVisible) { @@ -731,6 +734,11 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, height = if (keyboardVisible) imeInsets.bottom else navInsets.bottom } + binding.contentContainer.updatePadding( + left = systemBarsInsets.left, + right = systemBarsInsets.right + ) + windowInsets } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index 447527134c..10297977f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -140,7 +140,7 @@ class ConversationReactionOverlay : FrameLayout { // Use your existing utility to handle insets applySafeInsetsPaddings( - typeMask = WindowInsetsCompat.Type.systemBars(), + typeMask = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), consumeInsets = false, // Don't consume so children can also access them applyTop = false, // Don't apply as padding, just capture the values applyBottom = false @@ -211,7 +211,17 @@ class ConversationReactionOverlay : FrameLayout { val contextMenu = ConversationContextMenu(dropdownAnchor, recipient?.let { getMenuActionItems(messageRecord, it) }.orEmpty()) this.contextMenu = contextMenu - var endX = if (isMessageOnLeft) scrubberHorizontalMargin.toFloat() else selectedConversationModel.bubbleX - conversationItem.width + selectedConversationModel.bubbleWidth + // Visual left/right edges that account for system insets and configured margin. + val leftEdge = (systemInsets.left + scrubberHorizontalMargin).toFloat() + val rightEdge = (width - systemInsets.right - scrubberHorizontalMargin).toFloat() + + // Start the bubble aligned to the same visual edge as the scrubber. + var endX = if (isMessageOnLeft) { + leftEdge + } else { + rightEdge - conversationItem.width + } + var endY = selectedConversationModel.bubbleY - statusBarHeight conversationItem.x = endX conversationItem.y = endY @@ -312,13 +322,23 @@ class ConversationReactionOverlay : FrameLayout { // Adjust for system insets reactionBarBackgroundY = maxOf(reactionBarBackgroundY, systemInsets.top.toFloat() - statusBarHeight) + + // Now that endScale is final, clamp the bubble X so it stays fully within the visual edges. + val minBubbleX = leftEdge + val maxBubbleX = rightEdge + endX = endX.coerceIn(minBubbleX, maxBubbleX) + // Ensure initial position is corrected before making the overlay visible. + conversationItem.x = endX + conversationItem.y = endY + hideAnimatorSet.end() visibility = VISIBLE + // Place the scrubber on the same visual edges (accounting for its own width on the right). val scrubberX = if (isMessageOnLeft) { - scrubberHorizontalMargin.toFloat() + leftEdge } else { - (width - scrubberWidth - scrubberHorizontalMargin).toFloat() + (rightEdge - scrubberWidth) } foregroundView.x = scrubberX @@ -332,22 +352,37 @@ class ConversationReactionOverlay : FrameLayout { revealAnimatorSet.start() if (isWideLayout) { - val scrubberRight = scrubberX + scrubberWidth - val offsetX = when { - isMessageOnLeft -> scrubberRight + menuPadding - else -> scrubberX - contextMenu.getMaxWidth() - menuPadding + val menuXInOverlay = if (isMessageOnLeft) { + // Menu to the RIGHT of the scrubber + scrubberX + scrubberWidth + menuPadding + } else { + // Menu to the LEFT of the scrubber - use MENU width here, not scrubber width + scrubberX - contextMenu.getMaxWidth() - menuPadding } - // Adjust Y position to account for insets - val adjustedY = minOf(backgroundView.y, (availableHeight - actualMenuHeight).toFloat()).toInt() - contextMenu.show(offsetX.toInt(), adjustedY) + + val maxMenuYInOverlay = (height - systemInsets.bottom - actualMenuHeight).toFloat() + val menuYInOverlay = minOf(backgroundView.y, maxMenuYInOverlay) + + // Convert overlay-local to anchor relative as expected by ConversationContextMenu.show() + val (xOffset, yOffset) = toAnchorOffsets(menuXInOverlay, menuYInOverlay) + contextMenu.show(xOffset, yOffset) + } else { - val contentX = if (isMessageOnLeft) scrubberHorizontalMargin.toFloat() else selectedConversationModel.bubbleX - val offsetX = when { - isMessageOnLeft -> contentX - else -> -contextMenu.getMaxWidth() + contentX + bubbleWidth + val menuXInOverlay = if (isMessageOnLeft) { + leftEdge + } else { + rightEdge - contextMenu.getMaxWidth() } + val menuTop = endApparentTop + conversationItemSnapshot.height * endScale - contextMenu.show(offsetX.toInt(), (menuTop + menuPadding).toInt()) + val menuYInOverlay = (menuTop + menuPadding) + .coerceIn( + systemInsets.top.toFloat(), + (height - systemInsets.bottom - actualMenuHeight).toFloat() + ) + + val (xOffset, yOffset) = toAnchorOffsets(menuXInOverlay, menuYInOverlay) + contextMenu.show(xOffset, yOffset) } val revealDuration = context.resources.getInteger(R.integer.reaction_scrubber_reveal_duration) @@ -361,6 +396,12 @@ class ConversationReactionOverlay : FrameLayout { .setDuration(revealDuration.toLong()) } + private fun toAnchorOffsets(xInOverlay: Float, yInOverlay: Float): Pair { + val xOffset = (xInOverlay - dropdownAnchor.x).toInt() + val yOffset = (yInOverlay - dropdownAnchor.y).toInt() + return xOffset to yOffset + } + private fun getReactionBarOffsetForTouch(itemY: Float, contextMenuTop: Float, contextMenuPadding: Float, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt index eb1619d472..e2e89f8ab3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt @@ -8,7 +8,7 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.retain.retain import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -107,7 +107,7 @@ fun ConversationV2Dialogs( // delete message(s) if(dialogsState.deleteEveryone != null){ val data = dialogsState.deleteEveryone - var deleteForEveryone by remember { mutableStateOf(data.defaultToEveryone)} + var deleteForEveryone by retain { mutableStateOf(data.defaultToEveryone)} AlertDialog( onDismissRequest = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt index 2492c99893..0585b02992 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.retain.retain import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -287,7 +288,7 @@ fun GroupAdminClearMessagesDialog( groupName: String, sendCommand: (ConversationSettingsViewModel.Commands) -> Unit, ){ - var deleteForEveryone by remember { mutableStateOf(false) } + var deleteForEveryone by retain { mutableStateOf(false) } val context = LocalContext.current diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 3460c6f325..1a656be1c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -9,12 +9,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.retain.retain import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.dropUnlessResumed import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.NavBackStackEntry import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute @@ -26,19 +26,29 @@ import org.session.libsession.utilities.Address import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesViewModel import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.DisappearingMessagesScreen -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.* +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteAllMedia +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteConversationSettings +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteDisappearingMessages +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteGroupMembers +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteInviteAccountIdToGroup +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteInviteToCommunity +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteInviteToGroup +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteManageAdmins +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteManageMembers +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteNotifications +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RoutePromoteMembers import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsScreen import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsViewModel -import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel import org.thoughtcrime.securesms.groups.GroupMembersViewModel import org.thoughtcrime.securesms.groups.InviteMembersViewModel import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel import org.thoughtcrime.securesms.groups.PromoteMembersViewModel -import org.thoughtcrime.securesms.groups.compose.ManageGroupMembersScreen import org.thoughtcrime.securesms.groups.compose.GroupMembersScreen import org.thoughtcrime.securesms.groups.compose.InviteAccountIdScreen import org.thoughtcrime.securesms.groups.compose.InviteContactsScreen import org.thoughtcrime.securesms.groups.compose.ManageGroupAdminsScreen +import org.thoughtcrime.securesms.groups.compose.ManageGroupMembersScreen import org.thoughtcrime.securesms.groups.compose.PromoteMembersScreen import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessageViewModel import org.thoughtcrime.securesms.home.startconversation.newmessage.State @@ -46,7 +56,6 @@ import org.thoughtcrime.securesms.media.MediaOverviewScreen import org.thoughtcrime.securesms.media.MediaOverviewViewModel import org.thoughtcrime.securesms.ui.NavigationAction import org.thoughtcrime.securesms.ui.ObserveAsEvents -import org.thoughtcrime.securesms.ui.OpenURLAlertDialog import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.handleIntent import org.thoughtcrime.securesms.ui.horizontalSlideComposable @@ -155,7 +164,7 @@ fun ConversationSettingsNavHost( ){ SharedTransitionLayout { val navController = rememberNavController() - val navigator: UINavigator = remember { UINavigator() } + val navigator: UINavigator = retain { UINavigator() } val handleBack: () -> Unit = { if (navController.previousBackStackEntry != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt index c2a6015c86..42a859d736 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -112,7 +113,7 @@ fun ConversationSettings( } ) }, - contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal), ) { paddings -> Column( diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt index a23216759c..4a7ab5199f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -11,11 +11,15 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState @@ -41,11 +45,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.retain.retain import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.KeyboardType @@ -98,11 +104,11 @@ fun DebugMenu( val datePickerState = rememberDatePickerState() val timePickerState = rememberTimePickerState() - var showingDeprecatedDatePicker by remember { mutableStateOf(false) } - var showingDeprecatedTimePicker by remember { mutableStateOf(false) } + var showingDeprecatedDatePicker by retain { mutableStateOf(false) } + var showingDeprecatedTimePicker by retain { mutableStateOf(false) } - var showingDeprecatingStartDatePicker by remember { mutableStateOf(false) } - var showingDeprecatingStartTimePicker by remember { mutableStateOf(false) } + var showingDeprecatingStartDatePicker by retain { mutableStateOf(false) } + var showingDeprecatingStartTimePicker by retain { mutableStateOf(false) } val getPickedTime = { val localDate = ZonedDateTime.ofInstant( @@ -121,7 +127,8 @@ fun DebugMenu( }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) - } + }, + contentWindowInsets = WindowInsets.safeDrawing, ) { contentPadding -> // display a snackbar when required LaunchedEffect(uiState.snackMessage) { @@ -173,13 +180,21 @@ fun DebugMenu( LoadingDialog(title = "Applying changes...") } + val layoutDirection = LocalLayoutDirection.current + val safeInsetsPadding = PaddingValues( + start = contentPadding.calculateStartPadding(layoutDirection) + LocalDimensions.current.spacing, + end = contentPadding.calculateEndPadding(layoutDirection) + LocalDimensions.current.spacing, + top = contentPadding.calculateTopPadding(), + bottom = contentPadding.calculateBottomPadding(), + ) + Column( modifier = Modifier .background(LocalColors.current.background) - .padding(horizontal = LocalDimensions.current.spacing) + .padding(safeInsetsPadding) .verticalScroll(rememberScrollState()) ) { - Spacer(modifier = Modifier.height(contentPadding.calculateTopPadding())) + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) // Info pane val clipboardManager = LocalClipboardManager.current @@ -284,7 +299,8 @@ fun DebugMenu( style = LocalType.current.base ) DropDown( - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .padding(top = LocalDimensions.current.xxsSpacing), selectedText = uiState.selectedDebugSubscriptionStatus.label, values = uiState.debugSubscriptionStatuses.map { it.label }, @@ -315,7 +331,8 @@ fun DebugMenu( style = LocalType.current.base ) DropDown( - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .padding(top = LocalDimensions.current.xxsSpacing), selectedText = uiState.selectedDebugProPlanStatus.label, values = uiState.debugProPlanStatus.map { it.label }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt index cc4cece795..2de029463a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt @@ -5,6 +5,7 @@ import android.os.Parcelable import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.runtime.retain.retain import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.compose.NavHost @@ -40,7 +41,7 @@ fun DebugMenuNavHost( onBack: () -> Unit ){ val navController = rememberNavController() - val navigator: UINavigator = remember { + val navigator: UINavigator = retain { UINavigator() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java deleted file mode 100644 index 67c27f5032..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java +++ /dev/null @@ -1,157 +0,0 @@ -package org.thoughtcrime.securesms.giph.ui; - -import android.annotation.SuppressLint; -import android.content.Intent; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Bundle; -import android.view.View; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.viewpager2.adapter.FragmentStateAdapter; - -import com.google.android.material.tabs.TabLayoutMediator; - -import org.session.libsession.utilities.MediaTypes; -import org.session.libsession.utilities.NonTranslatableStringConstants; -import org.thoughtcrime.securesms.ScreenLockActionBarActivity; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.providers.BlobUtils; -import org.session.libsession.utilities.ViewUtil; - -import java.io.IOException; -import java.util.concurrent.ExecutionException; - -import network.loki.messenger.R; -import network.loki.messenger.databinding.GiphyActivityBinding; - -public class GiphyActivity extends ScreenLockActionBarActivity - implements GiphyActivityToolbar.OnLayoutChangedListener, - GiphyActivityToolbar.OnFilterChangedListener, - GiphyAdapter.OnItemClickListener -{ - - private static final String TAG = GiphyActivity.class.getSimpleName(); - - public static final String EXTRA_IS_MMS = "extra_is_mms"; - public static final String EXTRA_WIDTH = "extra_width"; - public static final String EXTRA_HEIGHT = "extra_height"; - - private GiphyGifFragment gifFragment; - private GiphyStickerFragment stickerFragment; - private boolean forMms; - - private GiphyActivityBinding binding; - - private GiphyAdapter.GiphyViewHolder finishingImage; - - @Override - public void onCreate(Bundle bundle, boolean ready) { - binding = GiphyActivityBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - - initializeToolbar(); - initializeResources(); - } - - private void initializeToolbar() { - GiphyActivityToolbar toolbar = ViewUtil.findById(this, R.id.giphy_toolbar); - toolbar.setOnFilterChangedListener(this); - toolbar.setOnLayoutChangedListener(this); - toolbar.setPersistence(GiphyActivityToolbarTextSecurePreferencesPersistence.fromContext(this)); - - setSupportActionBar(toolbar); - - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - getSupportActionBar().setHomeButtonEnabled(true); - } - - private void initializeResources() { - this.gifFragment = new GiphyGifFragment(); - this.stickerFragment = new GiphyStickerFragment(); - this.forMms = getIntent().getBooleanExtra(EXTRA_IS_MMS, false); - - binding.giphyPager.setAdapter(new GiphyFragmentPagerAdapter(this)); - - new TabLayoutMediator(binding.tabLayout, binding.giphyPager, (tab, position) -> { - tab.setText(position == 0 ? NonTranslatableStringConstants.GIF : getString(R.string.stickers)); - }).attach(); - } - - @Override - public void onFilterChanged(String filter) { - this.gifFragment.setSearchString(filter); - this.stickerFragment.setSearchString(filter); - } - - @Override - public void onLayoutChanged(boolean gridLayout) { - gifFragment.setLayoutManager(gridLayout); - stickerFragment.setLayoutManager(gridLayout); - } - - @SuppressLint("StaticFieldLeak") - @Override - public void onClick(final GiphyAdapter.GiphyViewHolder viewHolder) { - if (finishingImage != null) finishingImage.gifProgress.setVisibility(View.GONE); - finishingImage = viewHolder; - finishingImage.gifProgress.setVisibility(View.VISIBLE); - - new AsyncTask() { - @Override - protected Uri doInBackground(Void... params) { - try { - byte[] data = viewHolder.getData(forMms); - - return BlobUtils.getInstance() - .forData(data) - .withMimeType(MediaTypes.IMAGE_GIF) - .createForSingleSessionOnDisk(GiphyActivity.this, e -> Log.w(TAG, "Failed to write to disk.", e)) - .get(); - } catch (InterruptedException | ExecutionException | IOException e) { - Log.w(TAG, e); - return null; - } - } - - protected void onPostExecute(@Nullable Uri uri) { - if (uri == null) { - Toast.makeText(GiphyActivity.this, R.string.errorUnknown, Toast.LENGTH_LONG).show(); - } else if (viewHolder == finishingImage) { - Intent intent = new Intent(); - intent.setData(uri); - intent.putExtra(EXTRA_WIDTH, viewHolder.image.getGifWidth()); - intent.putExtra(EXTRA_HEIGHT, viewHolder.image.getGifHeight()); - setResult(RESULT_OK, intent); - finish(); - } else { - Log.w(TAG, "Resolved Uri is no longer the selected element..."); - } - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - private class GiphyFragmentPagerAdapter extends FragmentStateAdapter { - - private GiphyFragmentPagerAdapter(@NonNull FragmentActivity activity) - { - super(activity); - } - - @NonNull - @Override - public Fragment createFragment(int position) { - return position == 0 ? gifFragment : stickerFragment; - } - - @Override - public int getItemCount() { - return 2; - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.kt new file mode 100644 index 0000000000..8e7e7b8ce3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.kt @@ -0,0 +1,158 @@ +package org.thoughtcrime.securesms.giph.ui; + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import androidx.viewpager2.adapter.FragmentStateAdapter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import network.loki.messenger.R +import network.loki.messenger.databinding.GiphyActivityBinding +import org.session.libsession.utilities.MediaTypes +import org.session.libsession.utilities.ViewUtil +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.ScreenLockActionBarActivity +import org.thoughtcrime.securesms.giph.ui.compose.GiphyTabsCompose +import org.thoughtcrime.securesms.providers.BlobUtils +import org.thoughtcrime.securesms.ui.setThemedContent + +class GiphyActivity : + ScreenLockActionBarActivity(), + GiphyActivityToolbar.OnLayoutChangedListener, + GiphyActivityToolbar.OnFilterChangedListener, + GiphyAdapter.OnItemClickListener { + + companion object { + private val TAG = GiphyActivity::class.java.simpleName + + const val EXTRA_IS_MMS = "extra_is_mms" + const val EXTRA_WIDTH = "extra_width" + const val EXTRA_HEIGHT = "extra_height" + } + + private lateinit var binding: GiphyActivityBinding + + private lateinit var gifFragment: GiphyGifFragment + private lateinit var stickerFragment: GiphyStickerFragment + private var forMms: Boolean = false + + private var finishingImage: GiphyAdapter.GiphyViewHolder? = null + + private val titles = listOf( + R.string.gif, + R.string.stickers + ) + + override fun onCreate(bundle: Bundle?, ready: Boolean) { + binding = GiphyActivityBinding.inflate(layoutInflater) + setContentView(binding.root) + + initializeToolbar() + initializeResources() + + val pager = binding.giphyPager + + binding.composeTabs.apply { + setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed + ) + setThemedContent { + GiphyTabsCompose(pager, titles) + } + } + } + + private fun initializeToolbar() { + val toolbar: GiphyActivityToolbar = ViewUtil.findById(this, R.id.giphy_toolbar) + toolbar.setOnFilterChangedListener(this) + toolbar.setOnLayoutChangedListener(this) + toolbar.setPersistence(GiphyActivityToolbarTextSecurePreferencesPersistence.fromContext(this)) + + setSupportActionBar(toolbar) + supportActionBar?.apply { + setDisplayHomeAsUpEnabled(true) + setHomeButtonEnabled(true) + } + } + + private fun initializeResources() { + gifFragment = GiphyGifFragment() + stickerFragment = GiphyStickerFragment() + forMms = intent.getBooleanExtra(EXTRA_IS_MMS, false) + + binding.giphyPager.adapter = GiphyFragmentPagerAdapter(this) + } + + // Toolbar -> fragments + override fun onFilterChanged(filter: String) { + gifFragment.setNewSearchString(filter) + stickerFragment.setNewSearchString(filter) + } + + override fun onLayoutChanged(gridLayout: Boolean) { + gifFragment.setLayoutManager(gridLayout) + stickerFragment.setLayoutManager(gridLayout) + } + + override fun onClick(viewHolder: GiphyAdapter.GiphyViewHolder) { + finishingImage?.gifProgress?.visibility = View.GONE + finishingImage = viewHolder + finishingImage?.gifProgress?.visibility = View.VISIBLE + + lifecycleScope.launch { + val uri: Uri? = withContext(Dispatchers.IO) { + try { + val data = viewHolder.getData(forMms) + BlobUtils.getInstance() + .forData(data) + .withMimeType(MediaTypes.IMAGE_GIF) + .createForSingleSessionOnDisk( + this@GiphyActivity + ) { e -> Log.w(TAG, "Failed to write to disk.", e) } + .get() + } catch (t: Throwable) { + Log.w(TAG, t) + null + } + } + + if (isFinishing || isDestroyed) return@launch + finishingImage?.gifProgress?.visibility = View.GONE + + if (uri == null) { + Toast.makeText(this@GiphyActivity, R.string.errorUnknown, Toast.LENGTH_LONG).show() + return@launch + } + + // Only finish if the same cell is still the "finishing" one + if (viewHolder === finishingImage) { + val result = Intent().apply { + data = uri + putExtra(EXTRA_WIDTH, viewHolder.image.gifWidth) + putExtra(EXTRA_HEIGHT, viewHolder.image.gifHeight) + } + setResult(RESULT_OK, result) + finish() + } else { + Log.w(TAG, "Resolved Uri is no longer the selected element...") + } + } + } + + private inner class GiphyFragmentPagerAdapter(activity: FragmentActivity) : + FragmentStateAdapter(activity) { + + override fun getItemCount(): Int = 2 + + override fun createFragment(position: Int): Fragment { + return if (position == 0) gifFragment else stickerFragment + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyAdapter.java index b884ef6a19..a686d34bf6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyAdapter.java @@ -38,7 +38,7 @@ import network.loki.messenger.R; -class GiphyAdapter extends RecyclerView.Adapter { +public class GiphyAdapter extends RecyclerView.Adapter { private static final String TAG = GiphyAdapter.class.getSimpleName(); @@ -48,7 +48,7 @@ class GiphyAdapter extends RecyclerView.Adapter { private List images; private OnItemClickListener listener; - class GiphyViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, RequestListener { + public class GiphyViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, RequestListener { public AspectRatioImageView thumbnail; public GiphyImage image; diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java deleted file mode 100644 index 753e43b833..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java +++ /dev/null @@ -1,148 +0,0 @@ -package org.thoughtcrime.securesms.giph.ui; - -import android.os.AsyncTask; -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.loader.app.LoaderManager; -import androidx.loader.content.Loader; -import androidx.recyclerview.widget.DefaultItemAnimator; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.StaggeredGridLayoutManager; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import org.thoughtcrime.securesms.giph.model.GiphyImage; -import org.thoughtcrime.securesms.giph.net.GiphyLoader; -import org.thoughtcrime.securesms.giph.util.InfiniteScrollListener; -import com.bumptech.glide.Glide; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.ViewUtil; - -import java.util.LinkedList; -import java.util.List; - -import network.loki.messenger.R; - -public abstract class GiphyFragment extends Fragment implements LoaderManager.LoaderCallbacks>, GiphyAdapter.OnItemClickListener { - - private static final String TAG = GiphyFragment.class.getSimpleName(); - - private GiphyAdapter giphyAdapter; - private RecyclerView recyclerView; - private View loadingProgress; - private TextView noResultsView; - - protected String searchString; - private Boolean pendingGridLayout = null; - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) { - ViewGroup container = ViewUtil.inflate(inflater, viewGroup, R.layout.giphy_fragment); - this.recyclerView = ViewUtil.findById(container, R.id.giphy_list); - this.loadingProgress = ViewUtil.findById(container, R.id.loading_progress); - this.noResultsView = ViewUtil.findById(container, R.id.no_results); - - // Now that views are ready, apply the searchString if it's set - applySearchStringToUI(); - - // Apply pending layout if it was set before view was ready - if (pendingGridLayout != null) { - setLayoutManager(pendingGridLayout); - pendingGridLayout = null; - } else { - // Or set default - setLayoutManager(TextSecurePreferences.isGifSearchInGridLayout(getContext())); - } - - return container; - } - - @Override - public void onActivityCreated(Bundle bundle) { - super.onActivityCreated(bundle); - - this.giphyAdapter = new GiphyAdapter(getActivity(), Glide.with(this), new LinkedList<>()); - this.giphyAdapter.setListener(this); - - this.recyclerView.setItemAnimator(new DefaultItemAnimator()); - this.recyclerView.setAdapter(giphyAdapter); - this.recyclerView.addOnScrollListener(new GiphyScrollListener()); - - getLoaderManager().initLoader(0, null, this); - } - - @Override - public void onLoadFinished(@NonNull Loader> loader, @NonNull List data) { - this.loadingProgress.setVisibility(View.GONE); - - if (data.isEmpty()) noResultsView.setVisibility(View.VISIBLE); - else noResultsView.setVisibility(View.GONE); - - this.giphyAdapter.setImages(data); - } - - @Override - public void onLoaderReset(@NonNull Loader> loader) { - noResultsView.setVisibility(View.GONE); - this.giphyAdapter.setImages(new LinkedList()); - } - - public void setLayoutManager(boolean gridLayout) { - if (recyclerView != null) { - recyclerView.setLayoutManager(getLayoutManager(gridLayout)); - } else { - pendingGridLayout = gridLayout; - } - } - - private RecyclerView.LayoutManager getLayoutManager(boolean gridLayout) { - return gridLayout ? new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL) - : new LinearLayoutManager(getActivity()); - } - - - public void setSearchString(@Nullable String searchString) { - this.searchString = searchString; - if (this.noResultsView != null) { - applySearchStringToUI(); - } - } - - private void applySearchStringToUI() { - if (this.noResultsView != null) { - this.noResultsView.setVisibility(View.GONE); - this.getLoaderManager().restartLoader(0, null, this); - } - } - - @Override - public void onClick(GiphyAdapter.GiphyViewHolder viewHolder) { - if (getActivity() instanceof GiphyAdapter.OnItemClickListener) { - ((GiphyAdapter.OnItemClickListener) getActivity()).onClick(viewHolder); - } - } - - private class GiphyScrollListener extends InfiniteScrollListener { - @Override - public void onLoadMore(final int currentPage) { - final Loader> loader = getLoaderManager().getLoader(0); - if (loader == null) return; - - new AsyncTask>() { - @Override - protected List doInBackground(Void... params) { - return ((GiphyLoader)loader).loadPage(currentPage * GiphyLoader.PAGE_SIZE); - } - - protected void onPostExecute(List images) { - giphyAdapter.addImages(images); - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.kt new file mode 100644 index 0000000000..03789b89e7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.kt @@ -0,0 +1,163 @@ +package org.thoughtcrime.securesms.giph.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.loader.app.LoaderManager +import androidx.loader.content.Loader +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.StaggeredGridLayoutManager +import com.bumptech.glide.Glide +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import network.loki.messenger.R +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.ViewUtil +import org.thoughtcrime.securesms.giph.model.GiphyImage +import org.thoughtcrime.securesms.giph.net.GiphyLoader +import org.thoughtcrime.securesms.giph.ui.compose.GiphyOverlayState +import org.thoughtcrime.securesms.giph.ui.compose.bindGiphyOverlay +import org.thoughtcrime.securesms.giph.util.InfiniteScrollListener +import java.util.LinkedList +import java.util.List + +/** + * Base fragment for both GIF and Sticker tabs. + * Subclasses (Gif/Sticker) only implement onCreateLoader(...). + */ +abstract class GiphyFragment : + Fragment(), + LoaderManager.LoaderCallbacks> { + + private lateinit var giphyAdapter: GiphyAdapter + private lateinit var recyclerView: RecyclerView + private lateinit var overlayView: ComposeView + private val overlayState: MutableStateFlow = MutableStateFlow( + GiphyOverlayState.Hidden + ) + + // Set by toolbar filter via Activity + var searchString: String? = null + + // If setLayoutManager is called before views are ready + private var pendingGridLayout: Boolean? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, // nullable per Fragment API + savedInstanceState: Bundle? + ): View { + val root = inflater.inflate(R.layout.giphy_fragment, container, false) as ViewGroup + + // Todo: Make compose + recyclerView = root.findViewById(R.id.giphy_list) + + overlayView = ViewUtil.findById(root, R.id.giphy_state_overlay) + bindGiphyOverlay(overlayView, overlayState) + + applySearchStringToUI() + + pendingGridLayout?.let { + setLayoutManager(it) + pendingGridLayout = null + } ?: run { + setLayoutManager(TextSecurePreferences.isGifSearchInGridLayout(requireContext())) + } + + overlayState.value = GiphyOverlayState.Loading + + return root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + giphyAdapter = GiphyAdapter(requireActivity(), Glide.with(this), LinkedList()) + giphyAdapter.setListener { viewHolder -> + (activity as? GiphyAdapter.OnItemClickListener)?.onClick( + viewHolder + ) + } + + recyclerView.itemAnimator = DefaultItemAnimator() + recyclerView.adapter = giphyAdapter + recyclerView.addOnScrollListener(GiphyScrollListener()) + + // Initialize the loader (id = 0) + LoaderManager.getInstance(this).initLoader(0, null, this) + } + + // Loader callbacks (subclasses provide the loader) + abstract override fun onCreateLoader( + id: Int, + args: Bundle? + ): Loader> + + override fun onLoadFinished( + loader: Loader>, + data: List + ) { + overlayState.value = if (data.isEmpty()) GiphyOverlayState.Empty() else GiphyOverlayState.Hidden + giphyAdapter.setImages(data.toMutableList()) + } + + override fun onLoaderReset(loader: Loader>) { + overlayState.value = GiphyOverlayState.Hidden + giphyAdapter.setImages(mutableListOf()) + } + + fun setLayoutManager(gridLayout: Boolean) { + if (this::recyclerView.isInitialized) { + recyclerView.layoutManager = createLayoutManager(gridLayout) + } else { + pendingGridLayout = gridLayout + } + } + + fun setNewSearchString(newSearch: String?) { + searchString = newSearch + if (isAdded) applySearchStringToUI() + } + + private fun createLayoutManager(gridLayout: Boolean): RecyclerView.LayoutManager { + return if (gridLayout) { + StaggeredGridLayoutManager( + 2, + StaggeredGridLayoutManager.VERTICAL + ).apply { + gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS + } + } else { + LinearLayoutManager(requireActivity()) + } + } + + private fun applySearchStringToUI() { + overlayState.value = GiphyOverlayState.Loading + LoaderManager.getInstance(this).restartLoader(0, null, this) + } + + // Infinite scroll + private inner class GiphyScrollListener : InfiniteScrollListener() { + override fun onLoadMore(currentPage: Int) { + @Suppress("UNCHECKED_CAST") + val loader = LoaderManager.getInstance(this@GiphyFragment) + .getLoader>(0) as? GiphyLoader ?: return + + viewLifecycleOwner.lifecycleScope.launch { + val images = withContext(Dispatchers.IO) { + loader.loadPage(currentPage * GiphyLoader.PAGE_SIZE) + } + giphyAdapter.addImages(images.toMutableList()) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyGifFragment.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyGifFragment.java deleted file mode 100644 index 4c8ad66dcf..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyGifFragment.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.thoughtcrime.securesms.giph.ui; - - -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.loader.content.Loader; - -import org.thoughtcrime.securesms.giph.model.GiphyImage; -import org.thoughtcrime.securesms.giph.net.GiphyGifLoader; - -import java.util.List; - -public class GiphyGifFragment extends GiphyFragment { - - @Override - public @NonNull Loader> onCreateLoader(int id, Bundle args) { - return new GiphyGifLoader(getActivity(), searchString); - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyGifFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyGifFragment.kt new file mode 100644 index 0000000000..537f8b5edf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyGifFragment.kt @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.giph.ui; + + +import android.os.Bundle; +import androidx.loader.content.Loader; + +import org.thoughtcrime.securesms.giph.model.GiphyImage; +import org.thoughtcrime.securesms.giph.net.GiphyGifLoader; + +import java.util.List; + +@Suppress("UNCHECKED_CAST") +class GiphyGifFragment : GiphyFragment() { + override fun onCreateLoader( + id: Int, + args: Bundle? + ): Loader> { + return GiphyGifLoader(requireActivity(), searchString) + as Loader> + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyStickerFragment.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyStickerFragment.java deleted file mode 100644 index 0b838b4718..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyStickerFragment.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.thoughtcrime.securesms.giph.ui; - - -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.loader.content.Loader; - -import org.thoughtcrime.securesms.giph.model.GiphyImage; -import org.thoughtcrime.securesms.giph.net.GiphyStickerLoader; - -import java.util.List; - -public class GiphyStickerFragment extends GiphyFragment { - @Override - public @NonNull Loader> onCreateLoader(int id, Bundle args) { - return new GiphyStickerLoader(getActivity(), searchString); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyStickerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyStickerFragment.kt new file mode 100644 index 0000000000..ec66dcc160 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyStickerFragment.kt @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.giph.ui; + + +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.loader.content.Loader; + +import org.thoughtcrime.securesms.giph.model.GiphyImage; +import org.thoughtcrime.securesms.giph.net.GiphyStickerLoader; + +import java.util.List; + +@Suppress("UNCHECKED_CAST") +class GiphyStickerFragment : GiphyFragment() { + override fun onCreateLoader( + id: Int, + args: Bundle? + ): Loader> { + return GiphyStickerLoader(requireActivity(), searchString) + as Loader> + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/compose/GiphyFragmentCompose.kt b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/compose/GiphyFragmentCompose.kt new file mode 100644 index 0000000000..bc07d85e92 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/compose/GiphyFragmentCompose.kt @@ -0,0 +1,68 @@ +package org.thoughtcrime.securesms.giph.ui.compose + +import androidx.annotation.StringRes +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.StateFlow +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType + +sealed class GiphyOverlayState { + data object Hidden : GiphyOverlayState() + data object Loading : GiphyOverlayState() + data class Empty(val messageId: Int = R.string.searchMatchesNone) : GiphyOverlayState() +} + +fun bindGiphyOverlay(composeView: ComposeView, stateFlow: StateFlow) { + composeView.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + composeView.setContent { + val state by stateFlow.collectAsState() + GiphyOverlay(state) + } +} + +@Composable +private fun GiphyOverlay(state: GiphyOverlayState) { + Box(modifier = Modifier.fillMaxSize()) { + Crossfade(targetState = state, label = "giphyOverlay") { s -> + when (s) { + is GiphyOverlayState.Hidden -> {} + is GiphyOverlayState.Loading -> { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = LocalDimensions.current.spacing), + contentAlignment = Alignment.TopCenter + ) { + CircularProgressIndicator() + } + } + is GiphyOverlayState.Empty -> { + Box( + modifier = Modifier.fillMaxWidth().padding(LocalDimensions.current.spacing), + contentAlignment = Alignment.TopCenter + ) { + Text( + text = stringResource(s.messageId), + style = LocalType.current.large + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/compose/GiphyTabsCompose.kt b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/compose/GiphyTabsCompose.kt new file mode 100644 index 0000000000..8cc874db07 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/compose/GiphyTabsCompose.kt @@ -0,0 +1,41 @@ +@file:JvmName("GiphyTabsCompose") // lets Java call attachComposeTabs(...) +package org.thoughtcrime.securesms.giph.ui.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.viewpager2.widget.ViewPager2 +import org.thoughtcrime.securesms.ui.components.SessionTabRow + +@Composable +fun GiphyTabsCompose( + pager: ViewPager2, + titles: List +) { + var selectedIndex by rememberSaveable { + mutableIntStateOf(pager.currentItem.coerceIn(0, titles.lastIndex)) + } + + // Keep pager -> tabs selection in sync. + DisposableEffect(pager) { + val callback = object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + selectedIndex = position + } + } + pager.registerOnPageChangeCallback(callback) + onDispose { pager.unregisterOnPageChangeCallback(callback) } + } + + // Tabs -> ViewPager2 + SessionTabRow( + selectedIndex = selectedIndex, + titles = titles, + onTabSelected = { index -> + if (index != pager.currentItem) pager.setCurrentItem(index, true) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt index 4b2d4d24ad..407689f748 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt @@ -3,22 +3,37 @@ package org.thoughtcrime.securesms.groups.compose import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults 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.retain.retain import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -29,11 +44,16 @@ import org.thoughtcrime.securesms.groups.ContactItem import org.thoughtcrime.securesms.groups.GroupMemberState import org.thoughtcrime.securesms.groups.InviteMembersViewModel import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.CollapsibleFooterAction +import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.ProBadgeText import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.SearchBarWithClose +import org.thoughtcrime.securesms.ui.adaptive.getAdaptiveInfo import org.thoughtcrime.securesms.ui.components.Avatar +import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton import org.thoughtcrime.securesms.ui.components.RadioButtonIndicator import org.thoughtcrime.securesms.ui.components.annotatedStringResource @@ -99,7 +119,9 @@ fun MemberItem( Avatar( size = LocalDimensions.current.iconLarge, data = avatarUIData, - badge = if (showAsAdmin) { AvatarBadge.ResourceBadge.Admin } else AvatarBadge.None + badge = if (showAsAdmin) { + AvatarBadge.ResourceBadge.Admin + } else AvatarBadge.None ) Column( @@ -190,7 +212,7 @@ fun InviteMembersDialog( onInviteClicked: (Boolean) -> Unit, onDismiss: () -> Unit, ) { - var shareHistory by remember { mutableStateOf(true) } + var shareHistory by retain { mutableStateOf(true) } AlertDialog( modifier = modifier, @@ -270,6 +292,99 @@ fun ManageMemberItem( ) } +@Composable +fun MembersSearchHeader( + searchFocused: Boolean, + searchQuery: String, + onQueryChange: (String) -> Unit, + onClear: () -> Unit, + onFocusChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + placeholder: String = LocalResources.current.getString(R.string.search) +) { + Column( + modifier = modifier + .fillMaxWidth() + // important for stickyHeader so rows don't show through + .background(LocalColors.current.background) + .padding(vertical = LocalDimensions.current.smallSpacing) + ) { + SearchBarWithClose( + query = searchQuery, + onValueChanged = onQueryChange, + onClear = onClear, + placeholder = if (searchFocused) "" else placeholder, + enabled = enabled, + isFocused = searchFocused, + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), + onFocusChanged = onFocusChanged + ) + } +} + +@Composable +fun CollapsibleFooterBottomBar( + footer: CollapsibleFooterActionData, + onToggle: () -> Unit, + onClose: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)) + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)) + .imePadding() + ) { + CollapsibleFooterAction( + data = footer, + onCollapsedClicked = onToggle, + onClosedClicked = onClose + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BaseManageGroupScreen( + title: String, + onBack: () -> Unit, + enableCollapsingTopBarInLandscape: Boolean, + collapseTopBar : Boolean = false, + bottomBar: @Composable () -> Unit, + content: @Composable (paddingValues: PaddingValues) -> Unit, +) { + val isLandscape = getAdaptiveInfo().isLandscape + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + + val scaffoldModifier = + if (enableCollapsingTopBarInLandscape && isLandscape) + Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + else + Modifier + + LaunchedEffect(isLandscape, collapseTopBar) { + if (isLandscape && collapseTopBar) { + scrollBehavior.state.heightOffset = scrollBehavior.state.heightOffsetLimit + } + } + + Scaffold( + modifier = scaffoldModifier, + topBar = { + BackAppBar( + title = title, + onBack = onBack, + scrollBehavior = if (enableCollapsingTopBarInLandscape && isLandscape) scrollBehavior else null + ) + }, + bottomBar = bottomBar, + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal), + ) { paddingValues -> + content(paddingValues) + } +} + @Preview @Composable fun PreviewMemberList() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt index e5b937f475..56610cbbe5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt @@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.emptyFlow import org.thoughtcrime.securesms.groups.InviteMembersViewModel import org.thoughtcrime.securesms.home.startconversation.newmessage.Callbacks import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessage -import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessageViewModel import org.thoughtcrime.securesms.home.startconversation.newmessage.State import org.thoughtcrime.securesms.ui.OpenURLAlertDialog diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt index 61586660fc..07a177de5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt @@ -4,21 +4,12 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -44,9 +35,7 @@ import org.thoughtcrime.securesms.ui.CollapsibleFooterAction import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.SearchBarWithClose -import org.thoughtcrime.securesms.ui.components.BackAppBar -import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.adaptive.getAdaptiveInfo import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -90,6 +79,9 @@ fun InviteContacts( forCommunity: Boolean = false ) { + val searchFocused = uiState.isSearchFocused + val isLandscape = getAdaptiveInfo().isLandscape + val trayItems = listOf( CollapsibleFooterItemData( label = GetString(LocalResources.current.getString(R.string.membersInvite)), @@ -104,65 +96,53 @@ fun InviteContacts( val handleBack: () -> Unit = { when { - uiState.isSearchFocused -> sendCommand(RemoveSearchState(false)) + searchFocused -> sendCommand(RemoveSearchState(false)) else -> onBack() } } + val header: @Composable (Modifier) -> Unit = { modifier -> + MembersSearchHeader( + searchFocused = searchFocused, + searchQuery = searchQuery, + onQueryChange = { sendCommand(SearchQueryChange(it)) }, + onClear = { sendCommand(SearchQueryChange("")) }, + onFocusChanged = { sendCommand(SearchFocusChange(it)) }, + modifier = modifier + ) + } + + // Intercept system back BackHandler(enabled = true) { handleBack() } - Scaffold( - contentWindowInsets = WindowInsets.safeDrawing, - topBar = { - BackAppBar( - title = stringResource(id = R.string.membersInvite), - onBack = handleBack, - ) - }, + BaseManageGroupScreen( + title = stringResource(id = R.string.membersInvite), + onBack = handleBack, + enableCollapsingTopBarInLandscape = true, + collapseTopBar = searchFocused, bottomBar = { - Box( - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) - .imePadding() - ) { - CollapsibleFooterAction( - data = CollapsibleFooterActionData( - title = uiState.footer.footerActionTitle, - collapsed = uiState.footer.collapsed, - visible = uiState.footer.visible, - items = trayItems - ), - onCollapsedClicked = { sendCommand(ToggleFooter) }, - onClosedClicked = { sendCommand(CloseFooter) } - ) - } + CollapsibleFooterBottomBar( + footer = CollapsibleFooterActionData( + title = uiState.footer.footerActionTitle, + collapsed = uiState.footer.collapsed, + visible = uiState.footer.visible, + items = trayItems + ), + onToggle = { sendCommand(ToggleFooter) }, + onClose = { sendCommand(CloseFooter) } + ) } - ) { paddings -> + ) { paddingValues -> + Column( modifier = Modifier - .padding(paddings) - .consumeWindowInsets(paddings), + .padding(paddingValues) + .consumeWindowInsets(paddingValues), ) { - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - - if (hasContacts) { - SearchBarWithClose( - query = searchQuery, - onValueChanged = { query -> sendCommand(SearchQueryChange(query)) }, - onClear = { sendCommand(SearchQueryChange("")) }, - placeholder = stringResource(R.string.searchContacts), - modifier = Modifier - .padding(horizontal = LocalDimensions.current.smallSpacing) - .qaTag(R.string.AccessibilityId_groupNameSearch), - backgroundColor = LocalColors.current.backgroundSecondary, - isFocused = uiState.isSearchFocused, - onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) }, - enabled = true, - ) - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + if (!isLandscape && hasContacts) { + header(Modifier) } val scrollState = rememberLazyListState() @@ -185,6 +165,10 @@ fun InviteContacts( state = scrollState, contentPadding = PaddingValues(bottom = LocalDimensions.current.spacing), ) { + if (isLandscape && hasContacts) { + stickyHeader { header(Modifier) } + } + multiSelectMemberList( contacts = contacts, onContactItemClicked = { address -> sendCommand(ContactItemClick(address)) }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt index d1e0f2953d..992fde9f85 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.groups.compose -import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween @@ -8,34 +7,21 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -44,18 +30,21 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import network.loki.messenger.R import org.thoughtcrime.securesms.groups.GroupMemberState import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel -import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel.Commands.* -import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel.Commands.CloseFooter +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel.Commands.MemberClick +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel.Commands.RemoveSearchState +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel.Commands.SearchFocusChange +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel.Commands.SearchQueryChange +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel.Commands.SelfClick +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel.Commands.ToggleFooter import org.thoughtcrime.securesms.ui.Cell -import org.thoughtcrime.securesms.ui.CollapsibleFooterAction import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData import org.thoughtcrime.securesms.ui.Divider import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.ItemButton import org.thoughtcrime.securesms.ui.LoadingDialog -import org.thoughtcrime.securesms.ui.SearchBarWithClose -import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.adaptive.getAdaptiveInfo import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.getCellBottomShape import org.thoughtcrime.securesms.ui.getCellTopShape @@ -94,6 +83,7 @@ fun ManageAdmins( ) { val searchFocused = uiState.isSearchFocused + val isLandscape = getAdaptiveInfo().isLandscape val handleBack: () -> Unit = { when { @@ -102,37 +92,52 @@ fun ManageAdmins( } } + val searchLabel: @Composable (Modifier) -> Unit = { modifier -> + if (!searchFocused) { + Text( + modifier = Modifier.padding( + start = LocalDimensions.current.mediumSpacing + ), + text = LocalResources.current.getString(R.string.admins), + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + } + } + + val searchHeader: @Composable (Modifier) -> Unit = { modifier -> + MembersSearchHeader( + searchFocused = searchFocused, + searchQuery = searchQuery, + onQueryChange = { sendCommand(SearchQueryChange(it)) }, + onClear = { sendCommand(SearchQueryChange("")) }, + onFocusChanged = { sendCommand(SearchFocusChange(it)) }, + modifier = modifier + ) + } + // Intercept system back BackHandler(enabled = true) { handleBack() } - Scaffold( - topBar = { - BackAppBar( - title = stringResource(id = R.string.manageAdmins), - onBack = handleBack, - ) - }, + BaseManageGroupScreen( + title = stringResource(id = R.string.manageAdmins), + onBack = handleBack, + enableCollapsingTopBarInLandscape = true, + collapseTopBar = searchFocused, bottomBar = { - Box( - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) - .imePadding() - ) { - CollapsibleFooterAction( - data = CollapsibleFooterActionData( - title = uiState.footer.footerActionTitle, - collapsed = uiState.footer.collapsed, - visible = uiState.footer.visible, - items = uiState.footer.footerActionItems - ), - onCollapsedClicked = { sendCommand(ToggleFooter) }, - onClosedClicked = { sendCommand(CloseFooter) } - ) - } - }, - contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + CollapsibleFooterBottomBar( + footer = CollapsibleFooterActionData( + title = uiState.footer.footerActionTitle, + collapsed = uiState.footer.collapsed, + visible = uiState.footer.visible, + items = uiState.footer.footerActionItems + ), + onToggle = { sendCommand(ToggleFooter) }, + onClose = { sendCommand(CloseFooter) } + ) + } ) { paddingValues -> + Column( modifier = Modifier .padding(paddingValues) @@ -149,82 +154,31 @@ fun ManageAdmins( color = LocalColors.current.textSecondary ) - AnimatedVisibility( - // show only when add-members is enabled AND search is not focused - visible = !searchFocused, - enter = fadeIn(animationSpec = tween(150)) + - expandVertically( - animationSpec = tween(200), - expandFrom = Alignment.Top - ), - exit = fadeOut(animationSpec = tween(150)) + - shrinkVertically( - animationSpec = tween(180), - shrinkTowards = Alignment.Top - ) - ) { - Column { - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - - Cell( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = LocalDimensions.current.smallSpacing), - ) { - Column { - uiState.options.forEachIndexed { index, option -> - ItemButton( - modifier = Modifier.qaTag(option.qaTag), - text = annotatedStringResource(option.name), - iconRes = option.icon, - shape = when (index) { - 0 -> getCellTopShape() - uiState.options.lastIndex -> getCellBottomShape() - else -> RectangleShape - }, - onClick = option.onClick, - ) - - if (index != uiState.options.lastIndex) Divider() - } - } - } - } - } - - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - - if (!searchFocused) { - Text( - modifier = Modifier.padding( - start = LocalDimensions.current.mediumSpacing, - bottom = LocalDimensions.current.smallSpacing - ), - text = LocalResources.current.getString(R.string.admins), - style = LocalType.current.base, - color = LocalColors.current.textSecondary + if (!isLandscape) { + OptionsBlock( + show = !searchFocused, + options = uiState.options ) + searchLabel(Modifier) + searchHeader(Modifier) } - SearchBarWithClose( - query = searchQuery, - onValueChanged = { query -> sendCommand(SearchQueryChange(query)) }, - onClear = { sendCommand(SearchQueryChange("")) }, - placeholder = if (searchFocused) "" else LocalResources.current.getString(R.string.search), - enabled = true, - isFocused = searchFocused, - modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), - onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) } - ) - - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - // List of members LazyColumn( modifier = Modifier .weight(1f) .imePadding() ) { + if (isLandscape) { + item { + OptionsBlock( + show = !searchFocused, + options = uiState.options + ) + } + item { searchLabel(Modifier) } + stickyHeader { searchHeader(Modifier) } + } items(admins) { member -> // Each member's view ManageMemberItem( @@ -237,12 +191,6 @@ fun ManageAdmins( selected = member in selectedMembers ) } - - item { - Spacer( - modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) - ) - } } } } @@ -252,6 +200,52 @@ fun ManageAdmins( } } +@Composable +private fun OptionsBlock( + show: Boolean, + options: List, +) { + AnimatedVisibility( + visible = show, + enter = fadeIn(animationSpec = tween(150)) + + expandVertically( + animationSpec = tween(200), + expandFrom = Alignment.Top + ), + exit = fadeOut(animationSpec = tween(150)) + + shrinkVertically( + animationSpec = tween(180), + shrinkTowards = Alignment.Top + ) + ) { + Column { + Cell( + modifier = Modifier + .fillMaxWidth() + .padding(LocalDimensions.current.smallSpacing), + ) { + Column { + options.forEachIndexed { index, option -> + ItemButton( + modifier = Modifier.qaTag(option.qaTag), + text = annotatedStringResource(option.name), + iconRes = option.icon, + shape = when (index) { + 0 -> getCellTopShape() + options.lastIndex -> getCellBottomShape() + else -> RectangleShape + }, + onClick = option.onClick, + ) + + if (index != options.lastIndex) Divider() + } + } + } + } + } + +} @Preview @Composable diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt index e84031445a..fd197fd347 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.groups.compose -import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween @@ -8,38 +7,25 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.retain.retain import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -47,15 +33,21 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import network.loki.messenger.R import network.loki.messenger.libsession_util.util.GroupMember -import org.session.libsession.utilities.Address import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.groups.GroupMemberState import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.CollapsibleFooterState -import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.Commands.* +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.Commands.CloseFooter +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.Commands.DismissRemoveMembersDialog +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.Commands.MemberClick +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.Commands.RemoveMembers +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.Commands.RemoveSearchState +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.Commands.SearchFocusChange +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.Commands.SearchQueryChange +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.Commands.ToggleFooter +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.OptionsItem import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.Cell -import org.thoughtcrime.securesms.ui.CollapsibleFooterAction import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData import org.thoughtcrime.securesms.ui.DialogButtonData @@ -64,8 +56,7 @@ import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.ItemButton import org.thoughtcrime.securesms.ui.LoadingDialog import org.thoughtcrime.securesms.ui.RadioOption -import org.thoughtcrime.securesms.ui.SearchBarWithClose -import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.adaptive.getAdaptiveInfo import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.getCellBottomShape @@ -112,6 +103,7 @@ fun ManageMembers( ) { val searchFocused = uiState.isSearchFocused + val isLandscape = getAdaptiveInfo().isLandscape val handleBack: () -> Unit = { when { @@ -120,114 +112,90 @@ fun ManageMembers( } } + val searchLabel: @Composable (Modifier) -> Unit = { modifier -> + if (!searchFocused) { + Text( + modifier = Modifier.padding( + start = LocalDimensions.current.mediumSpacing + ), + text = LocalResources.current.getString(R.string.membersNonAdmins), + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + } + } + + val header: @Composable (Modifier) -> Unit = { modifier -> + MembersSearchHeader( + searchFocused = searchFocused, + searchQuery = searchQuery, + onQueryChange = { sendCommand(SearchQueryChange(it)) }, + onClear = { sendCommand(SearchQueryChange("")) }, + onFocusChanged = { sendCommand(SearchFocusChange(it)) }, + modifier = modifier + ) + } + // Intercept system back BackHandler(enabled = true) { handleBack() } - Scaffold( - topBar = { - BackAppBar( - title = stringResource(id = R.string.manageMembers), - onBack = handleBack, - ) - }, + BaseManageGroupScreen( + title = stringResource(id = R.string.manageMembers), + onBack = handleBack, + enableCollapsingTopBarInLandscape = true, + collapseTopBar = searchFocused, bottomBar = { - Box( - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) - .imePadding() - ) { - CollapsibleFooterAction( - data = CollapsibleFooterActionData( - title = uiState.footer.footerActionTitle, - collapsed = uiState.footer.collapsed, - visible = uiState.footer.visible, - items = uiState.footer.footerActionItems - ), - onCollapsedClicked = { sendCommand(ToggleFooter) }, - onClosedClicked = { sendCommand(CloseFooter) } - ) - } - }, - contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + CollapsibleFooterBottomBar( + footer = CollapsibleFooterActionData( + title = uiState.footer.footerActionTitle, + collapsed = uiState.footer.collapsed, + visible = uiState.footer.visible, + items = uiState.footer.footerActionItems + ), + onToggle = { sendCommand(ToggleFooter) }, + onClose = { sendCommand(CloseFooter) } + ) + } ) { paddingValues -> Column( modifier = Modifier .padding(paddingValues) .consumeWindowInsets(paddingValues) ) { - - AnimatedVisibility( - // show only when add-members is enabled AND search is not focused - visible = showAddMembers && !searchFocused, - enter = fadeIn(animationSpec = tween(150)) + - expandVertically( - animationSpec = tween(200), - expandFrom = Alignment.Top - ), - exit = fadeOut(animationSpec = tween(150)) + - shrinkVertically( - animationSpec = tween(180), - shrinkTowards = Alignment.Top - ) - ) { - Cell( - modifier = Modifier - .fillMaxWidth() - .padding(LocalDimensions.current.smallSpacing), - ) { - Column { - uiState.options.forEachIndexed { index, option -> - ItemButton( - modifier = Modifier.qaTag(option.qaTag), - text = annotatedStringResource(option.name), - iconRes = option.icon, - shape = when (index) { - 0 -> getCellTopShape() - uiState.options.lastIndex -> getCellBottomShape() - else -> RectangleShape - }, - onClick = option.onClick, - ) - - if (index != uiState.options.lastIndex) Divider() - } - } - } + // PORTRAIT: options OUTSIDE scroll + if (!isLandscape) { + OptionsBlock( + show = showAddMembers && !searchFocused, + options = uiState.options + ) } if (hasMembers) { - if (!searchFocused) { - Text( - modifier = Modifier.padding( - start = LocalDimensions.current.mediumSpacing, - bottom = LocalDimensions.current.smallSpacing - ), - text = LocalResources.current.getString(R.string.membersNonAdmins), - style = LocalType.current.base, - color = LocalColors.current.textSecondary - ) + if (!isLandscape) { + searchLabel(Modifier) + header(Modifier) } - SearchBarWithClose( - query = searchQuery, - onValueChanged = { query -> sendCommand(SearchQueryChange(query)) }, - onClear = { sendCommand(SearchQueryChange("")) }, - placeholder = if (searchFocused) "" else LocalResources.current.getString(R.string.search), - enabled = true, - isFocused = searchFocused, - modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), - onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) } - ) - - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - // List of members LazyColumn( modifier = Modifier .weight(1f) - .imePadding() ) { + // LANDSCAPE: options INSIDE scroll + if (isLandscape) { + item(key = "options") { + OptionsBlock( + show = showAddMembers && !searchFocused, + options = uiState.options + ) + } + + item { searchLabel(Modifier) } + stickyHeader { + header(Modifier) + } + } + items(members) { member -> // Each member's view ManageMemberItem( @@ -237,12 +205,6 @@ fun ManageMembers( selected = member in selectedMembers ) } - - item { - Spacer( - modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) - ) - } } } else { Text( @@ -271,13 +233,50 @@ fun ManageMembers( } } +@Composable +private fun OptionsBlock( + show: Boolean, + options: List, // use your actual type +) { + AnimatedVisibility( + visible = show, + enter = fadeIn(animationSpec = tween(150)) + + expandVertically(animationSpec = tween(200), expandFrom = Alignment.Top), + exit = fadeOut(animationSpec = tween(150)) + + shrinkVertically(animationSpec = tween(180), shrinkTowards = Alignment.Top) + ) { + Cell( + modifier = Modifier + .fillMaxWidth() + .padding(LocalDimensions.current.smallSpacing), + ) { + Column { + options.forEachIndexed { index, option -> + ItemButton( + modifier = Modifier.qaTag(option.qaTag), + text = annotatedStringResource(option.name), + iconRes = option.icon, + shape = when (index) { + 0 -> getCellTopShape() + options.lastIndex -> getCellBottomShape() + else -> RectangleShape + }, + onClick = option.onClick + ) + if (index != options.lastIndex) Divider() + } + } + } + } +} + @Composable fun RemoveMembersDialog( state: ManageGroupMembersViewModel.RemoveMembersDialogState, modifier: Modifier = Modifier, sendCommand: (ManageGroupMembersViewModel.Commands) -> Unit ) { - var deleteMessages by remember { mutableStateOf(false) } + var deleteMessages by retain { mutableStateOf(false) } AlertDialog( modifier = modifier, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt index 59ce50cd97..5846d6844a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt @@ -1,26 +1,19 @@ package org.thoughtcrime.securesms.groups.compose import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -44,13 +37,11 @@ import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ShowCo import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ShowPromoteDialog import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ToggleFooter import org.thoughtcrime.securesms.ui.AlertDialog -import org.thoughtcrime.securesms.ui.CollapsibleFooterAction import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.SearchBarWithClose -import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.adaptive.getAdaptiveInfo import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -92,7 +83,9 @@ fun PromoteMembers( hasActiveMembers: Boolean = false, onPromoteClicked: (Set) -> Unit ) { + val searchFocused = uiState.isSearchFocused + val isLandscape = getAdaptiveInfo().isLandscape val handleBack: () -> Unit = { when { @@ -101,45 +94,46 @@ fun PromoteMembers( } } + val searchHeader: @Composable (Modifier) -> Unit = { modifier -> + MembersSearchHeader( + searchFocused = searchFocused, + searchQuery = searchQuery, + onQueryChange = { sendCommand(SearchQueryChange(it)) }, + onClear = { sendCommand(SearchQueryChange("")) }, + onFocusChanged = { sendCommand(SearchFocusChange(it)) }, + modifier = modifier + ) + } + // Intercept system back BackHandler(enabled = true) { handleBack() } - - Scaffold( - topBar = { - BackAppBar( - title = pluralStringResource(id = R.plurals.promoteMember, 2), - onBack = handleBack, - ) - }, + BaseManageGroupScreen( + title = pluralStringResource(id = R.plurals.promoteMember, 2), + onBack = handleBack, + enableCollapsingTopBarInLandscape = true, + collapseTopBar = searchFocused, bottomBar = { - Box( - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) - .imePadding() - ) { - CollapsibleFooterAction( - data = CollapsibleFooterActionData( - title = uiState.footer.footerTitle, - collapsed = uiState.footer.collapsed, - visible = uiState.footer.visible, - items = listOf( - CollapsibleFooterItemData( - label = uiState.footer.footerActionLabel, - buttonLabel = GetString(LocalResources.current.getString(R.string.promote)), - isDanger = false, - onClick = { sendCommand(ShowPromoteDialog) } - ) + CollapsibleFooterBottomBar( + footer = CollapsibleFooterActionData( + title = uiState.footer.footerTitle, + collapsed = uiState.footer.collapsed, + visible = uiState.footer.visible, + items = listOf( + CollapsibleFooterItemData( + label = uiState.footer.footerActionLabel, + buttonLabel = GetString(LocalResources.current.getString(R.string.promote)), + isDanger = false, + onClick = { sendCommand(ShowPromoteDialog) } ) - ), - onCollapsedClicked = { sendCommand(ToggleFooter) }, - onClosedClicked = { sendCommand(CloseFooter) } - ) - } - }, - contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + ) + ), + onToggle = { sendCommand(ToggleFooter) }, + onClose = { sendCommand(CloseFooter) } + ) + } ) { paddingValues -> + Column( modifier = Modifier .padding(paddingValues) @@ -157,20 +151,9 @@ fun PromoteMembers( ) if (hasActiveMembers) { - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - - SearchBarWithClose( - query = searchQuery, - onValueChanged = { query -> sendCommand(SearchQueryChange(query)) }, - onClear = { sendCommand(SearchQueryChange("")) }, - placeholder = if (searchFocused) "" else LocalResources.current.getString(R.string.search), - enabled = true, - isFocused = searchFocused, - modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), - onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) } - ) - - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + if (!isLandscape) { + searchHeader(Modifier) + } // List of members LazyColumn( @@ -178,6 +161,10 @@ fun PromoteMembers( .weight(1f) .imePadding() ) { + if (isLandscape) { + stickyHeader { searchHeader(Modifier) } + } + items(members) { member -> // Each member's view ManageMemberItem( diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt index f3f27e947b..3859154ee2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -1,11 +1,18 @@ package org.thoughtcrime.securesms.home +import android.content.Context +import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible +import androidx.core.view.updatePadding import androidx.core.widget.TextViewCompat +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R @@ -20,13 +27,15 @@ import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.NotifyType import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.ui.adaptive.getAdaptiveInfo import javax.inject.Inject @AndroidEntryPoint -class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListener { +class ConversationOptionsBottomSheet() : BottomSheetDialogFragment(), View.OnClickListener { private lateinit var binding: FragmentConversationBottomSheetBinding //FIXME AC: Supplying a threadRecord directly into the field from an activity // is not the best idea. It doesn't survive configuration change. @@ -43,6 +52,8 @@ class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClick @Inject lateinit var loginStateRepository: LoginStateRepository @Inject lateinit var groupManager : GroupManagerV2 + @Inject lateinit var threadDatabase: ThreadDatabase + var onViewDetailsTapped: (() -> Unit?)? = null var onCopyConversationId: (() -> Unit?)? = null var onPinTapped: (() -> Unit)? = null @@ -57,8 +68,43 @@ class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClick var onNotificationTapped: (() -> Unit)? = null var onDeleteContactTapped: (() -> Unit)? = null - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = FragmentConversationBottomSheetBinding.inflate(LayoutInflater.from(context), container, false) + + companion object { + const val FRAGMENT_TAG = "ConversationOptionsBottomSheet" + private const val ARG_PUBLIC_KEY = "arg_public_key" + const val ARG_THREAD_ID = "arg_thread_id" + const val ARG_ADDRESS = "arg_address" + + fun newInstance(publicKey: String, threadId: Long, address: String): ConversationOptionsBottomSheet { + return ConversationOptionsBottomSheet().apply { + arguments = Bundle().apply { + putString(ARG_PUBLIC_KEY, publicKey) + putLong(ARG_THREAD_ID, threadId) + putString(ARG_ADDRESS, address) + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val args = requireArguments() + publicKey = requireNotNull(args.getString(ARG_PUBLIC_KEY)) + requireNotNull(args.getLong(ARG_THREAD_ID)) + val addressString = requireNotNull(args.getString(ARG_ADDRESS)) + val address = Address.fromSerialized(addressString) + thread = requireNotNull( + threadDatabase.getThreads(listOf(address)).firstOrNull() + ) { "Thread not found for address: $addressString" } + group = groupDatabase.getGroup(thread.recipient.address.toString()).orNull() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentConversationBottomSheetBinding.inflate(inflater, container, false) return binding.root } @@ -239,5 +285,26 @@ class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClick super.onStart() val window = dialog?.window ?: return window.setDimAmount(0.6f) + + val dlg = dialog as? BottomSheetDialog ?: return + val sheet = dlg.findViewById(R.id.design_bottom_sheet) + ?: return + + if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE){ + val behavior = BottomSheetBehavior.from(sheet) + behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED + } + + ViewCompat.setOnApplyWindowInsetsListener(sheet) { _, insets -> + val cut = insets.getInsets( + WindowInsetsCompat.Type.systemBars() or + WindowInsetsCompat.Type.displayCutout() + ) + + binding.root.updatePadding(left = cut.left, right = cut.right) + insets + } + + ViewCompat.requestApplyInsets(sheet) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index e64503fa73..207c025ba9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -49,7 +49,7 @@ class ConversationView : LinearLayout { override fun onFinishInflate() { super.onFinishInflate() - layoutParams = RecyclerView.LayoutParams(screenWidth, RecyclerView.LayoutParams.WRAP_CONTENT) + (layoutParams as? RecyclerView.LayoutParams)?.width = RecyclerView.LayoutParams.MATCH_PARENT } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 67184ad12e..3b908d4266 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -9,6 +9,7 @@ import android.content.Intent import android.os.Build import android.os.Bundle import android.widget.Toast +import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels import androidx.compose.animation.Crossfade import androidx.compose.foundation.clickable @@ -19,7 +20,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.core.graphics.Insets import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.updatePadding @@ -100,7 +100,7 @@ import org.thoughtcrime.securesms.ui.theme.primaryGreen import org.thoughtcrime.securesms.util.AvatarBadge import org.thoughtcrime.securesms.util.AvatarUtils import org.thoughtcrime.securesms.util.DateUtils -import org.thoughtcrime.securesms.util.applySafeInsetsMargins +import org.thoughtcrime.securesms.util.applyBottomInsetMargin import org.thoughtcrime.securesms.util.applySafeInsetsPaddings import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.fadeIn @@ -270,6 +270,20 @@ class HomeActivity : ScreenLockActionBarActivity(), } binding.sessionToolbar.disableClipping() + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + val searchHandled = homeViewModel.isSearchOpen.value && + binding.globalSearchInputLayout.handleBackPressed() + if (searchHandled) return + + if (homeViewModel.onBackPressed()) { + return + } + + finish() + } + }) + lifecycleScope.launch { homeViewModel.shouldShowCurrentUserProBadge .collectLatest { @@ -465,6 +479,8 @@ class HomeActivity : ScreenLockActionBarActivity(), ) } + rewireConversationOptionsCallbacksIfPresent() + applyViewInsets() } @@ -602,95 +618,109 @@ class HomeActivity : ScreenLockActionBarActivity(), // endregion // region Interaction - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - if (homeViewModel.isSearchOpen.value && binding.globalSearchInputLayout.handleBackPressed()) { - return - } - - if (!homeViewModel.onBackPressed()) { - super.onBackPressed() - } - } override fun onConversationClick(thread: ThreadRecord) { push(ConversationActivityV2.createIntent(this, address = thread.recipient.address as Address.Conversable)) } override fun onLongConversationClick(thread: ThreadRecord) { - val bottomSheet = ConversationOptionsBottomSheet() - bottomSheet.publicKey = publicKey - bottomSheet.thread = thread val threadRecipient = thread.recipient - bottomSheet.group = groupDatabase.getGroup(threadRecipient.address.toString()).orNull() - bottomSheet.onViewDetailsTapped = { - bottomSheet.dismiss() + val bottomSheet = ConversationOptionsBottomSheet.newInstance( + publicKey = publicKey, + threadId = thread.threadId, + address = threadRecipient.address.toString() + ) + attachConversationOptionsCallbacks(bottomSheet, thread) + bottomSheet.show(supportFragmentManager, ConversationOptionsBottomSheet.FRAGMENT_TAG) + } + + /** + * If a ConversationOptionsBottomSheet was restored by FragmentManager after a + * configuration change, re-attach its callbacks and refresh the ThreadRecord. + */ + private fun rewireConversationOptionsCallbacksIfPresent() { + val sheet = supportFragmentManager + .findFragmentByTag(ConversationOptionsBottomSheet.FRAGMENT_TAG) + as? ConversationOptionsBottomSheet ?: return + + val threadId = sheet.requireArguments() + .getLong(ConversationOptionsBottomSheet.ARG_THREAD_ID) + + val threadRecord = homeViewModel.data.value?.items?.asSequence() + ?.filterIsInstance() + ?.firstOrNull { it.thread.threadId == threadId }?.thread + + threadRecord?.let { + attachConversationOptionsCallbacks(sheet, it) + } + } + + private fun attachConversationOptionsCallbacks( + sheet: ConversationOptionsBottomSheet, + thread: ThreadRecord + ) { + val threadRecipient = thread.recipient + sheet.onViewDetailsTapped = { + sheet.dismiss() homeViewModel.showUserProfileModal(thread) } - bottomSheet.onCopyConversationId = onCopyConversationId@{ - bottomSheet.dismiss() + sheet.onCopyConversationId = { + sheet.dismiss() if (threadRecipient.address is Address.WithAccountId && !threadRecipient.isSelf) { - val clip = ClipData.newPlainText("Account ID", threadRecipient.address.accountId.hexString) - val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager - manager.setPrimaryClip(clip) - Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show() - } - else if (threadRecipient.data is RecipientData.Community) { + val clip = ClipData.newPlainText( + "Account ID", + threadRecipient.address.accountId.hexString + ) + (getSystemService(CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(clip) + Toast.makeText(this@HomeActivity, R.string.copied, Toast.LENGTH_SHORT).show() + } else if (threadRecipient.data is RecipientData.Community) { val clip = ClipData.newPlainText("Community URL", threadRecipient.data.joinURL) - val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager - manager.setPrimaryClip(clip) - Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show() + (getSystemService(CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(clip) + Toast.makeText(this@HomeActivity, R.string.copied, Toast.LENGTH_SHORT).show() } } - bottomSheet.onBlockTapped = { - bottomSheet.dismiss() - if (!threadRecipient.blocked) { - blockConversation(thread) - } + sheet.onBlockTapped = { + sheet.dismiss() + if (!threadRecipient.blocked) blockConversation(thread) } - bottomSheet.onUnblockTapped = { - bottomSheet.dismiss() - if (threadRecipient.blocked) { - unblockConversation(thread) - } + sheet.onUnblockTapped = { + sheet.dismiss() + if (threadRecipient.blocked) unblockConversation(thread) } - bottomSheet.onAdminLeaveTapped = { - bottomSheet.dismiss() + sheet.onAdminLeaveTapped = { + sheet.dismiss() deleteConversation(thread, false) } - bottomSheet.onDeleteTapped = { - bottomSheet.dismiss() + sheet.onDeleteTapped = { + sheet.dismiss() deleteConversation(thread, true) } - bottomSheet.onNotificationTapped = { - bottomSheet.dismiss() - // go to the notification settings - val intent = Intent(this, NotificationSettingsActivity::class.java).apply { + sheet.onNotificationTapped = { + sheet.dismiss() + startActivity(Intent(this, NotificationSettingsActivity::class.java).apply { putExtra(NotificationSettingsActivity.ARG_ADDRESS, threadRecipient.address) - } - startActivity(intent) + }) } - bottomSheet.onPinTapped = { - bottomSheet.dismiss() + sheet.onPinTapped = { + sheet.dismiss() setConversationPinned(threadRecipient.address, true) } - bottomSheet.onUnpinTapped = { - bottomSheet.dismiss() + sheet.onUnpinTapped = { + sheet.dismiss() setConversationPinned(threadRecipient.address, false) } - bottomSheet.onMarkAllAsReadTapped = { - bottomSheet.dismiss() + sheet.onMarkAllAsReadTapped = { + sheet.dismiss() markAllAsRead(thread) } - bottomSheet.onMarkAsUnreadTapped = { - bottomSheet.dismiss() + sheet.onMarkAsUnreadTapped = { + sheet.dismiss() markAsUnread(thread) } - bottomSheet.onDeleteContactTapped = { - bottomSheet.dismiss() + sheet.onDeleteContactTapped = { + sheet.dismiss() confirmDeleteContact(thread) } - bottomSheet.show(supportFragmentManager, bottomSheet.tag) } private fun blockConversation(thread: ThreadRecord) { @@ -947,9 +977,9 @@ class HomeActivity : ScreenLockActionBarActivity(), } ) - binding.newConversationButton.applySafeInsetsMargins( + binding.newConversationButton.applyBottomInsetMargin( typeMask = WindowInsetsCompat.Type.navigationBars(), - additionalInsets = Insets.of(0,0,0, resources.getDimensionPixelSize(R.dimen.new_conversation_button_bottom_offset)) + extraBottom = resources.getDimensionPixelSize(R.dimen.new_conversation_button_bottom_offset) ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt index e6c3c06383..9831a9f6d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt @@ -4,7 +4,7 @@ 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.retain.retain import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -16,7 +16,17 @@ import org.session.libsession.utilities.StringSubstitutionConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY -import org.thoughtcrime.securesms.home.HomeViewModel.Commands.* +import org.thoughtcrime.securesms.home.HomeViewModel.Commands.GotoProSettings +import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HandleUserProfileCommand +import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HideDonationCTADialog +import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HideExpiredCTADialog +import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HidePinCTADialog +import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HideSimpleDialog +import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HideUrlDialog +import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HideUserProfileModal +import org.thoughtcrime.securesms.home.HomeViewModel.Commands.OnLinkCopied +import org.thoughtcrime.securesms.home.HomeViewModel.Commands.OnLinkOpened +import org.thoughtcrime.securesms.home.HomeViewModel.Commands.ShowDonationConfirmation import org.thoughtcrime.securesms.home.startconversation.StartConversationSheet import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination import org.thoughtcrime.securesms.ui.AlertDialog @@ -108,7 +118,7 @@ fun HomeDialogs( // we need a delay before displaying this. // Setting the delay in the VM does not account for render and it seems to appear immediately - var showExpiring by remember { mutableStateOf(false) } + var showExpiring by retain { mutableStateOf(false) } LaunchedEffect(dialogsState.proExpiringCTA) { showExpiring = false if (dialogsState.proExpiringCTA != null) { @@ -149,7 +159,7 @@ fun HomeDialogs( // we need a delay before displaying this. // Setting the delay in the VM does not account for render and it seems to appear immediately - var showExpired by remember { mutableStateOf(false) } + var showExpired by retain { mutableStateOf(false) } LaunchedEffect(dialogsState.proExpiredCTA) { showExpired = false if (dialogsState.proExpiredCTA) { @@ -190,7 +200,7 @@ fun HomeDialogs( // we need a delay before displaying this. // Setting the delay in the VM does not account for render and it seems to appear immediately - var showDonation by remember { mutableStateOf(false) } + var showDonation by retain { mutableStateOf(false) } LaunchedEffect(dialogsState.donationCTA) { showDonation = false if (dialogsState.donationCTA) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index d6be5d64ba..1ad7a2a202 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.PRIORITY_HIDDEN import org.session.libsession.database.StorageProtocol @@ -39,6 +40,7 @@ import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.dependencies.ConfigFactory diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/SearchContactActionBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/SearchContactActionBottomSheet.kt index 4633925d2b..64ad38bee3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/SearchContactActionBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/SearchContactActionBottomSheet.kt @@ -1,15 +1,25 @@ package org.thoughtcrime.securesms.home.search import android.content.Context +import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.core.os.BundleCompat +import androidx.core.view.doOnLayout +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint @@ -52,7 +62,11 @@ class SearchContactActionBottomSheet : BottomSheetDialogFragment() { savedInstanceState: Bundle? ): View = createThemedComposeView { Column( - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding( + WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) + ) ) { // Only standard address can be blocked if (address is Address.Standard) { @@ -116,6 +130,18 @@ class SearchContactActionBottomSheet : BottomSheetDialogFragment() { } } + override fun onStart() { + super.onStart() + + val dlg = dialog as? BottomSheetDialog ?: return + val sheet = dlg.findViewById(R.id.design_bottom_sheet) ?: return + + if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + val behavior = BottomSheetBehavior.from(sheet) + behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + companion object { private const val ARG_ADDRESS = "arg_address" private const val ARG_CONTACT_NAME = "arg_contact_name" diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt index c426fcfa6b..58a8a99a6d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt @@ -3,26 +3,28 @@ package org.thoughtcrime.securesms.home.startconversation import android.annotation.SuppressLint import androidx.activity.compose.LocalActivity import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionLayout import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue +import androidx.compose.runtime.retain.retain import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.compose.NavHost @@ -63,13 +65,26 @@ fun StartConversationSheet( modifier = modifier, sheetState = sheetState, dragHandle = null, - onDismissRequest = onDismissRequest + onDismissRequest = onDismissRequest, ){ - BoxWithConstraints(modifier = modifier) { + BoxWithConstraints { + val windowWidthDp = with(LocalDensity.current) { + LocalWindowInfo.current.containerSize.width.toDp() + } + + val isFullWidth = maxWidth >= windowWidthDp + + val horizontalInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) + val contentMod = if (isFullWidth) { + Modifier.windowInsetsPadding(horizontalInsets) + } else { + Modifier + } + val topInset = WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() val targetHeight = (this.maxHeight - topInset) * 0.94f // sheet should take up 94% of the height, without the staatus bar Box( - modifier = Modifier.height(targetHeight), + modifier = contentMod.height(targetHeight), contentAlignment = Alignment.TopCenter ) { StartConversationNavHost( @@ -113,7 +128,7 @@ fun StartConversationNavHost( ){ val navController = rememberNavController() val navigator: UINavigator = - remember { UINavigator() } + retain { UINavigator() } ObserveAsEvents(flow = navigator.navigationActions) { action -> when (action) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/home/StartConversation.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/home/StartConversation.kt index ce23c5147d..15a2bbb93e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/home/StartConversation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/home/StartConversation.kt @@ -1,12 +1,19 @@ package org.thoughtcrime.securesms.home.startconversation.home import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +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.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.verticalScroll @@ -25,11 +32,13 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min import androidx.compose.ui.unit.times import network.loki.messenger.R import org.thoughtcrime.securesms.home.startconversation.StartConversationDestination import org.thoughtcrime.securesms.ui.Divider import org.thoughtcrime.securesms.ui.ItemButton +import org.thoughtcrime.securesms.ui.adaptive.getAdaptiveInfo import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon import org.thoughtcrime.securesms.ui.components.BasicAppBar import org.thoughtcrime.securesms.ui.components.QrImage @@ -49,12 +58,17 @@ internal fun StartConversationScreen( navigateTo: (StartConversationDestination) -> Unit, onClose: () -> Unit, ) { - val context = LocalContext.current + val isLandscape = getAdaptiveInfo().isLandscape - Column(modifier = Modifier.background( - LocalColors.current.backgroundSecondary, - shape = MaterialTheme.shapes.small.copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp)) - )) { + Column( + modifier = Modifier.background( + LocalColors.current.backgroundSecondary, + shape = MaterialTheme.shapes.small.copy( + bottomStart = CornerSize(0.dp), + bottomEnd = CornerSize(0.dp) + ) + ) + ) { BasicAppBar( title = stringResource(R.string.conversationsStart), backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container @@ -65,100 +79,181 @@ internal fun StartConversationScreen( modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()), color = LocalColors.current.backgroundSecondary ) { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()) - ) { - val dividerIndent: Dp = LocalDimensions.current.itemButtonIconSpacing + 2*LocalDimensions.current.smallSpacing - val newMessageTitleTxt:String = context.resources.getQuantityString(R.plurals.messageNew, 1, 1) - val itemHeight = 50.dp + if (isLandscape) { + LandscapeContent(accountId, navigateTo) + } else { + PortraitContent(accountId, navigateTo) + } + } + } +} - ItemButton( - text = annotatedStringResource(newMessageTitleTxt), - textStyle = LocalType.current.xl, - iconRes = R.drawable.ic_message_square, - iconSize = LocalDimensions.current.iconMedium, - modifier = Modifier.qaTag(R.string.AccessibilityId_messageNew), - minHeight = itemHeight, - onClick = { - navigateTo(StartConversationDestination.NewMessage) - } - ) - Divider( - paddingValues = PaddingValues( - start = dividerIndent, - end = LocalDimensions.current.smallSpacing - ) - ) - ItemButton( - text = annotatedStringResource(R.string.groupCreate), - textStyle = LocalType.current.xl, - iconRes = R.drawable.ic_users_group_custom, - iconSize = LocalDimensions.current.iconMedium, - modifier = Modifier.qaTag(R.string.AccessibilityId_groupCreate), - minHeight = itemHeight, - onClick = { - navigateTo(StartConversationDestination.CreateGroup) - } - ) - Divider( - paddingValues = PaddingValues( - start = dividerIndent, - end = LocalDimensions.current.smallSpacing - ) - ) - ItemButton( - text = annotatedStringResource(R.string.communityJoin), - textStyle = LocalType.current.xl, - iconRes = R.drawable.ic_globe, - iconSize = LocalDimensions.current.iconMedium, - modifier = Modifier.qaTag(R.string.AccessibilityId_communityJoin), - minHeight = itemHeight, - onClick = { - navigateTo(StartConversationDestination.JoinCommunity) - } - ) - Divider( - paddingValues = PaddingValues( - start = dividerIndent, - end = LocalDimensions.current.smallSpacing - ) - ) - ItemButton( - text = annotatedStringResource(R.string.sessionInviteAFriend), - textStyle = LocalType.current.xl, - iconRes = R.drawable.ic_user_round_plus, - iconSize = LocalDimensions.current.iconMedium, - modifier = Modifier.qaTag(R.string.AccessibilityId_sessionInviteAFriendButton), - minHeight = itemHeight, - onClick = { - navigateTo(StartConversationDestination.InviteFriend) - } +@Composable +private fun PortraitContent( + accountId: String, + navigateTo: (StartConversationDestination) -> Unit +) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + ActionList(navigateTo = navigateTo) + QrPanel( + accountId = accountId, + modifier = Modifier + .fillMaxWidth() + .padding(LocalDimensions.current.spacing), + ) + } +} + +@Composable +private fun LandscapeContent( + accountId: String, + navigateTo: (StartConversationDestination) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth(), + ) { + // Left: independently scrollable actions list + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + ActionList(navigateTo = navigateTo) + } + + // Right: QR panel, vertically centered, with square sizing + Box( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + QrPanel( + accountId = accountId, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = LocalDimensions.current.spacing) + .padding(bottom = LocalDimensions.current.spacing) + ) + } + + } +} + +@Composable +private fun QrPanel( + accountId: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.widthIn(max = 420.dp), + ) { + Text(stringResource(R.string.accountIdYours), style = LocalType.current.xl) + Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) + Text( + text = stringResource(R.string.qrYoursDescription), + color = LocalColors.current.textSecondary, + style = LocalType.current.small + ) + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + BoxWithConstraints(modifier = Modifier) { + val qrModifier = if (getAdaptiveInfo().isLandscape) { + val shortest: Dp = min(maxWidth, maxHeight) + val qrSide = (shortest * 0.70f).coerceIn( + LocalDimensions.current.minContentSize, + LocalDimensions.current.maxContentSize ) - Column( - modifier = Modifier - .padding(horizontal = LocalDimensions.current.spacing) - .padding(top = LocalDimensions.current.spacing) - .padding(bottom = LocalDimensions.current.spacing) - ) { - Text(stringResource(R.string.accountIdYours), style = LocalType.current.xl) - Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) - Text( - text = stringResource(R.string.qrYoursDescription), - color = LocalColors.current.textSecondary, - style = LocalType.current.small - ) - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - QrImage( - string = accountId, - Modifier.qaTag(R.string.AccessibilityId_qrCode), - icon = R.drawable.session - ) - } + Modifier.size(qrSide) + } else { + Modifier } + QrImage( + string = accountId, + modifier = qrModifier + .qaTag(R.string.AccessibilityId_qrCode) + .aspectRatio(1f), + icon = R.drawable.session + ) } } } +@Composable +private fun ActionList(navigateTo: (StartConversationDestination) -> Unit) { + val context = LocalContext.current + + val dividerIndent: Dp = + LocalDimensions.current.itemButtonIconSpacing + 2 * LocalDimensions.current.smallSpacing + val newMessageTitleTxt: String = context.resources.getQuantityString(R.plurals.messageNew, 1, 1) + val itemHeight = 50.dp + + ItemButton( + text = annotatedStringResource(newMessageTitleTxt), + textStyle = LocalType.current.xl, + iconRes = R.drawable.ic_message_square, + iconSize = LocalDimensions.current.iconMedium, + minHeight = itemHeight, + modifier = Modifier.qaTag(R.string.AccessibilityId_messageNew), + onClick = { + navigateTo(StartConversationDestination.NewMessage) + } + ) + Divider( + paddingValues = PaddingValues( + start = dividerIndent, + end = LocalDimensions.current.smallSpacing + ) + ) + ItemButton( + text = annotatedStringResource(R.string.groupCreate), + textStyle = LocalType.current.xl, + iconRes = R.drawable.ic_users_group_custom, + iconSize = LocalDimensions.current.iconMedium, + minHeight = itemHeight, + modifier = Modifier.qaTag(R.string.AccessibilityId_groupCreate), + onClick = { + navigateTo(StartConversationDestination.CreateGroup) + } + ) + Divider( + paddingValues = PaddingValues( + start = dividerIndent, + end = LocalDimensions.current.smallSpacing + ) + ) + ItemButton( + text = annotatedStringResource(R.string.communityJoin), + textStyle = LocalType.current.xl, + iconRes = R.drawable.ic_globe, + iconSize = LocalDimensions.current.iconMedium, + minHeight = itemHeight, + modifier = Modifier.qaTag(R.string.AccessibilityId_communityJoin), + onClick = { + navigateTo(StartConversationDestination.JoinCommunity) + } + ) + Divider( + paddingValues = PaddingValues( + start = dividerIndent, + end = LocalDimensions.current.smallSpacing + ) + ) + ItemButton( + text = annotatedStringResource(R.string.sessionInviteAFriend), + textStyle = LocalType.current.xl, + iconRes = R.drawable.ic_user_round_plus, + iconSize = LocalDimensions.current.iconMedium, + minHeight = itemHeight, + modifier = Modifier.qaTag(R.string.AccessibilityId_sessionInviteAFriendButton), + onClick = { + navigateTo(StartConversationDestination.InviteFriend) + } + ) + +} + @Preview @Composable private fun PreviewStartConversationScreen( diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt index 6416061ba4..7e4ad51095 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt @@ -17,10 +17,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -32,7 +31,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.retain.retain import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -64,8 +63,8 @@ fun MediaOverviewScreen( val selectionMode by viewModel.inSelectionMode.collectAsState() val conversationName by viewModel.conversationName.collectAsState() val topAppBarState = rememberTopAppBarState() - var showingDeleteConfirmation by remember { mutableStateOf(false) } - var showingSaveAttachmentWarning by remember { mutableStateOf(false) } + var showingDeleteConfirmation by retain { mutableStateOf(false) } + var showingSaveAttachmentWarning by retain { mutableStateOf(false) } val context = LocalContext.current val requestStoragePermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> @@ -138,7 +137,7 @@ fun MediaOverviewScreen( appBarScrollBehavior = appBarScrollBehavior ) }, - contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) ) { paddings -> Column( modifier = Modifier diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXActivity.kt new file mode 100644 index 0000000000..029ce0e37b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXActivity.kt @@ -0,0 +1,366 @@ +package org.thoughtcrime.securesms.mediasend + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Bundle +import android.util.Log +import android.util.Size +import android.view.View +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.ImageProxy +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy +import androidx.camera.view.LifecycleCameraController +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityCameraxBinding +import org.session.libsession.utilities.MediaTypes +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.ScreenLockActionBarActivity +import org.thoughtcrime.securesms.providers.BlobUtils +import org.thoughtcrime.securesms.util.applySafeInsetsPaddings +import org.thoughtcrime.securesms.util.setSafeOnClickListener +import org.thoughtcrime.securesms.webrtc.Orientation +import org.thoughtcrime.securesms.webrtc.OrientationManager +import java.io.ByteArrayOutputStream +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import javax.inject.Inject + +@AndroidEntryPoint +class CameraXActivity : ScreenLockActionBarActivity() { + + override val applyDefaultWindowInsets: Boolean + get() = false + + companion object { + private const val TAG = "CameraXActivity" + private const val REQUEST_CODE_PERMISSIONS = 10 + private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA) + + const val EXTRA_IMAGE_URI = "extra_image_uri" + const val EXTRA_IMAGE_SIZE = "extra_image_size" + const val EXTRA_IMAGE_WIDTH = "extra_image_width" + const val EXTRA_IMAGE_HEIGHT = "extra_image_height" + + const val KEY_MEDIA_SEND_COUNT ="key_mediasend_count" + } + + private lateinit var binding: ActivityCameraxBinding + + private lateinit var cameraController: LifecycleCameraController + private lateinit var cameraExecutor: ExecutorService + private var cameraInitialized = false + + private var lastRotation: Orientation = Orientation.UNKNOWN + + private val portraitConstraints = ConstraintSet() + private val landscapeConstraints = ConstraintSet() + private lateinit var rootConstraintLayout: ConstraintLayout + + private var orientationManager = OrientationManager(this) + + @Inject + lateinit var prefs: TextSecurePreferences + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityCameraxBinding.inflate(layoutInflater) + setContentView(binding.root) + + cameraExecutor = Executors.newSingleThreadExecutor() + + rootConstraintLayout = binding.root + + // 1) Portrait constraints: from a portrait layout + portraitConstraints.clone(this, R.layout.activity_camerax_portrait) + // 2) Landscape constraints: cloned from a template XML + landscapeConstraints.clone(this, R.layout.activity_camerax_landscape) + + setupUi() + applyViewInsets() + initializeCountButton() + + // Permissions should ideally be handled before launching this Activity, + // but keep this as a safety check. + if (allPermissionsGranted()) { + startCamera() + } else { + ActivityCompat.requestPermissions( + this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS + ) + } + + lifecycleScope.launch { + orientationManager.orientation.collect { orientation -> + if (!orientationManager.isAutoRotateOn()) { + updateUiForRotation(orientation) + } + } + } + } + + private fun setupUi() { + binding.cameraCaptureButton.setSafeOnClickListener { takePhoto() } + binding.cameraFlipButton.setSafeOnClickListener { flipCamera() } + binding.cameraCloseButton.setSafeOnClickListener { + setResult(RESULT_CANCELED) + finish() + } + } + + private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all { + ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED + } + + private fun startCamera() { + // Work out a resolution based on available memory + val activityManager = + getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager + val memoryClassMb = activityManager.memoryClass + val preferredResolution: Size = when { + memoryClassMb >= 256 -> Size(1920, 1440) + memoryClassMb >= 128 -> Size(1280, 960) + else -> Size(640, 480) + } + Log.d( + TAG, + "Selected resolution: $preferredResolution based on memory class: $memoryClassMb MB" + ) + + val resolutionSelector = ResolutionSelector.Builder() + .setResolutionStrategy( + ResolutionStrategy( + preferredResolution, + ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER + ) + ) + .build() + + // Set up camera + cameraController = LifecycleCameraController(this).apply { + cameraSelector = prefs.getPreferredCameraDirection() + setImageCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) + setTapToFocusEnabled(true) + setPinchToZoomEnabled(true) + + // Configure image capture resolution + setImageCaptureResolutionSelector(resolutionSelector) + } + + // Attach it to the view + binding.previewView.controller = cameraController + cameraController.bindToLifecycle(this) + + // Wait for initialization to complete + cameraController.initializationFuture.addListener({ + cameraInitialized = true + updateFlipButtonVisibility() + }, ContextCompat.getMainExecutor(this)) + } + + private fun updateFlipButtonVisibility() { + if (!::cameraController.isInitialized) return + + val hasFront = cameraController.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA) + val hasBack = cameraController.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA) + + binding.cameraFlipButton.visibility = + if (hasFront && hasBack) View.VISIBLE else View.GONE + } + + private fun takePhoto() { + val isFrontCamera = cameraController.cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA + + cameraController.takePicture( + cameraExecutor, + object : ImageCapture.OnImageCapturedCallback() { + override fun onCaptureSuccess(img: ImageProxy) { + try { + val buffer = img.planes[0].buffer + val originalBytes = ByteArray(buffer.remaining()).also { buffer.get(it) } + val rotationDegrees = img.imageInfo.rotationDegrees + img.close() + + val bitmap = + BitmapFactory.decodeByteArray(originalBytes, 0, originalBytes.size) + var correctedBitmap = rotateBitmap(bitmap, rotationDegrees.toFloat()) + if (isFrontCamera) { + correctedBitmap = mirrorBitmap(correctedBitmap) + } + + val width = correctedBitmap.width + val height = correctedBitmap.height + + val outputStream = ByteArrayOutputStream() + correctedBitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) + val compressedBytes = outputStream.toByteArray() + + // Recycle bitmaps + bitmap.recycle() + if (correctedBitmap !== bitmap) correctedBitmap.recycle() + + val uri = BlobUtils.getInstance() + .forData(compressedBytes) + .withMimeType(MediaTypes.IMAGE_JPEG) + .createForSingleSessionInMemory() + + val data = Intent().apply { + data = uri + putExtra(EXTRA_IMAGE_URI, uri.toString()) + putExtra(EXTRA_IMAGE_SIZE, compressedBytes.size.toLong()) + putExtra(EXTRA_IMAGE_WIDTH, width) + putExtra(EXTRA_IMAGE_HEIGHT, height) + } + + setResult(RESULT_OK, data) + finish() + } catch (t: Throwable) { + Log.e(TAG, "capture failed", t) + setResult(RESULT_CANCELED) + finish() + } + } + + override fun onError(e: ImageCaptureException) { + Log.e(TAG, "takePicture error", e) + setResult(RESULT_CANCELED) + finish() + } + } + ) + } + + private fun mirrorBitmap(src: Bitmap): Bitmap { + val matrix = android.graphics.Matrix().apply { preScale(-1f, 1f) } + return Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true) + } + + private fun rotateBitmap(src: Bitmap, degrees: Float): Bitmap { + if (degrees == 0f) return src + val matrix = android.graphics.Matrix().apply { postRotate(degrees) } + return Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true) + } + + private fun flipCamera() { + if (!::cameraController.isInitialized) return + + val newSelector = + if (cameraController.cameraSelector == CameraSelector.DEFAULT_BACK_CAMERA) + CameraSelector.DEFAULT_FRONT_CAMERA + else + CameraSelector.DEFAULT_BACK_CAMERA + + cameraController.cameraSelector = newSelector + prefs.setPreferredCameraDirection(newSelector) + + // Animate icon (simple 180° spin; no manual orientation tracking) + binding.cameraFlipButton.animate() + .rotationBy(-180f) + .setDuration(200) + .start() + } + + private fun updateUiForRotation(rotation: Orientation = lastRotation) { + val rotation = + when (rotation) { + Orientation.LANDSCAPE -> -90f + Orientation.REVERSED_LANDSCAPE -> 90f + else -> 0f + } + + binding.cameraFlipButton.animate() + .rotation(rotation) + .setDuration(150) + .start() + } + + private fun initializeCountButton() { + val count = intent.getIntExtra(KEY_MEDIA_SEND_COUNT, 0) + + binding.mediasendCountContainer.mediasendCountButtonText.text = count.toString() + binding.mediasendCountContainer.mediasendCountButton.isEnabled = count > 0 + binding.mediasendCountContainer.mediasendCountButton.visibility = if(count >0) View.VISIBLE else View.INVISIBLE + if (count > 0) { + binding.mediasendCountContainer.mediasendCountButton.setOnClickListener { v: View? -> + setResult(RESULT_CANCELED) + finish() + } + } else { + binding.mediasendCountContainer.mediasendCountButton.setOnClickListener(null) + } + } + + private fun applyViewInsets(){ + binding.cameraCloseButton.applySafeInsetsPaddings() + binding.root.applySafeInsetsPaddings( + applyTop = false, + applyBottom = true, + consumeInsets = false + ) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + + if (!::rootConstraintLayout.isInitialized) return + + val isLandscape = newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE + + if (isLandscape) { + landscapeConstraints.applyTo(rootConstraintLayout) + } else { + portraitConstraints.applyTo(rootConstraintLayout) + } + if (cameraInitialized) { + updateFlipButtonVisibility() + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == REQUEST_CODE_PERMISSIONS) { + if (allPermissionsGranted()) { + startCamera() + } else { + setResult(RESULT_CANCELED) + finish() + } + } + } + + override fun onResume() { + super.onResume() + orientationManager.startOrientationListener() + } + + override fun onPause() { + super.onPause() + orientationManager.stopOrientationListener() + } + + override fun onDestroy() { + if (::cameraExecutor.isInitialized) { + cameraExecutor.shutdown() + } + super.onDestroy() + + orientationManager.destroy() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt deleted file mode 100644 index 6faff31f88..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt +++ /dev/null @@ -1,278 +0,0 @@ -package org.thoughtcrime.securesms.mediasend - -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.net.Uri -import android.os.Bundle -import android.util.Log -import android.util.Size -import android.view.LayoutInflater -import android.view.OrientationEventListener -import android.view.Surface -import android.view.View -import android.view.ViewGroup -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageCapture -import androidx.camera.core.ImageCaptureException -import androidx.camera.core.ImageProxy -import androidx.camera.core.resolutionselector.ResolutionSelector -import androidx.camera.core.resolutionselector.ResolutionStrategy -import androidx.camera.view.LifecycleCameraController -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment -import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.databinding.CameraxFragmentBinding -import org.session.libsession.utilities.MediaTypes -import org.session.libsession.utilities.TextSecurePreferences -import org.thoughtcrime.securesms.providers.BlobUtils -import org.thoughtcrime.securesms.util.applySafeInsetsMargins -import org.thoughtcrime.securesms.util.setSafeOnClickListener -import java.io.ByteArrayOutputStream -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import javax.inject.Inject - -@AndroidEntryPoint -class CameraXFragment : Fragment() { - - interface Controller { - fun onImageCaptured(imageUri: Uri, size: Long, width: Int, height: Int) - fun onCameraError() - } - - private lateinit var binding: CameraxFragmentBinding - - private var callbacks: Controller? = null - - private lateinit var cameraController: LifecycleCameraController - private lateinit var cameraExecutor: ExecutorService - - - private lateinit var orientationListener: OrientationEventListener - private var lastRotation: Int = Surface.ROTATION_0 - - @Inject - lateinit var prefs: TextSecurePreferences - - companion object { - private const val TAG = "CameraXFragment" - private const val REQUEST_CODE_PERMISSIONS = 10 - private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA) - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - binding = CameraxFragmentBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - cameraExecutor = Executors.newSingleThreadExecutor() - - // permissions should be handled prior to landing in this fragment - // but this is added for safety - if (allPermissionsGranted()) { - startCamera() - } else { - ActivityCompat.requestPermissions( - requireActivity(), REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS - ) - } - - binding.cameraControlsSafeArea.applySafeInsetsMargins() - - binding.cameraCaptureButton.setSafeOnClickListener { takePhoto() } - binding.cameraFlipButton.setSafeOnClickListener { flipCamera() } - binding.cameraCloseButton.setSafeOnClickListener { - requireActivity().onBackPressedDispatcher.onBackPressed() - } - - // keep track of orientation changes - orientationListener = object : OrientationEventListener(requireContext()) { - override fun onOrientationChanged(degrees: Int) { - if (degrees == ORIENTATION_UNKNOWN) return - - val newRotation = when { - degrees in 45..134 -> Surface.ROTATION_270 - degrees in 135..224 -> Surface.ROTATION_180 - degrees in 225..314 -> Surface.ROTATION_90 - else -> Surface.ROTATION_0 - } - - if (newRotation != lastRotation) { - lastRotation = newRotation - updateUiForRotation(newRotation) - } - } - } - } - - override fun onResume() { - super.onResume() - orientationListener.enable() - } - - override fun onPause() { - orientationListener.disable() - super.onPause() - } - - override fun onAttach(context: Context) { - super.onAttach(context) - if (context is Controller) { - callbacks = context - } else { - throw RuntimeException("$context must implement CameraXFragment.Controller") - } - } - - private fun updateUiForRotation(rotation: Int = lastRotation) { - val angle = when (rotation) { - Surface.ROTATION_0 -> 0f - Surface.ROTATION_90 -> 90f - Surface.ROTATION_180 -> 180f - else -> 270f - } - - binding.cameraFlipButton.animate() - .rotation(angle) - .setDuration(150) - .start() - } - - private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all { - ContextCompat.checkSelfPermission( - requireContext(), it - ) == PackageManager.PERMISSION_GRANTED - } - - private fun startCamera() { - // work out a resolution based on available memory - val activityManager = requireContext().getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager - val memoryClassMb = activityManager.memoryClass // e.g. 128, 256, etc. - val preferredResolution: Size = when { - memoryClassMb >= 256 -> Size(1920, 1440) - memoryClassMb >= 128 -> Size(1280, 960) - else -> Size(640, 480) - } - Log.d(TAG, "Selected resolution: $preferredResolution based on memory class: $memoryClassMb MB") - - val resolutionSelector = ResolutionSelector.Builder() - .setResolutionStrategy( - ResolutionStrategy( - preferredResolution, - ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER - ) - ) - .build() - - // set up camera - cameraController = LifecycleCameraController(requireContext()).apply { - cameraSelector = prefs.getPreferredCameraDirection() - setImageCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) - setTapToFocusEnabled(true) - setPinchToZoomEnabled(true) - - // Configure image capture resolution - setImageCaptureResolutionSelector(resolutionSelector) - } - - // attach it to the view - binding.previewView.controller = cameraController - cameraController.bindToLifecycle(viewLifecycleOwner) - - // wait for initialisation to complete - cameraController.initializationFuture.addListener({ - val hasFront = cameraController.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA) - val hasBack = cameraController.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA) - - binding.cameraFlipButton.visibility = - if (hasFront && hasBack) View.VISIBLE else View.GONE - }, ContextCompat.getMainExecutor(requireContext())) - } - - private fun takePhoto() { - val isFrontCamera = cameraController.cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA - cameraController.takePicture( - cameraExecutor, - object : ImageCapture.OnImageCapturedCallback() { - override fun onCaptureSuccess(img: ImageProxy) { - try { - val buffer = img.planes[0].buffer - val originalBytes = ByteArray(buffer.remaining()).also { buffer.get(it) } - val rotationDegrees = img.imageInfo.rotationDegrees - img.close() - - // Decode, rotate, mirror if needed - val bitmap = BitmapFactory.decodeByteArray(originalBytes, 0, originalBytes.size) - var correctedBitmap = rotateBitmap(bitmap, rotationDegrees.toFloat()) - if (isFrontCamera) { - correctedBitmap = mirrorBitmap(correctedBitmap) - } - - val outputStream = ByteArrayOutputStream() - correctedBitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) - val compressedBytes = outputStream.toByteArray() - - // Recycle bitmaps - bitmap.recycle() - if (correctedBitmap !== bitmap) correctedBitmap.recycle() - - val uri = BlobUtils.getInstance() - .forData(compressedBytes) - .withMimeType(MediaTypes.IMAGE_JPEG) - .createForSingleSessionInMemory() - - callbacks?.onImageCaptured(uri, compressedBytes.size.toLong(), correctedBitmap.width, correctedBitmap.height) - } catch (t: Throwable) { - Log.e(TAG, "capture failed", t) - callbacks?.onCameraError() - } - } - override fun onError(e: ImageCaptureException) { - Log.e(TAG, "takePicture error", e) - callbacks?.onCameraError() - } - } - ) - } - - private fun mirrorBitmap(src: Bitmap): Bitmap { - val matrix = android.graphics.Matrix().apply { preScale(-1f, 1f) } - return Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true) - } - - private fun rotateBitmap(src: Bitmap, degrees: Float): Bitmap { - if (degrees == 0f) return src - val matrix = android.graphics.Matrix().apply { postRotate(degrees) } - return Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true) - } - - private fun flipCamera() { - val newSelector = - if (cameraController.cameraSelector == CameraSelector.DEFAULT_BACK_CAMERA) - CameraSelector.DEFAULT_FRONT_CAMERA - else - CameraSelector.DEFAULT_BACK_CAMERA - - cameraController.cameraSelector = newSelector - prefs.setPreferredCameraDirection(newSelector) - - // animate icon - binding.cameraFlipButton.animate() - .rotationBy(-180f) - .setDuration(200) - .start() - } - - override fun onDestroyView() { - cameraExecutor.shutdown() - super.onDestroyView() - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java index f060bf4424..3bdf73322b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java @@ -131,7 +131,7 @@ private void initToolbar(Toolbar toolbar) { actionBar.setTitle(txt); actionBar.setDisplayHomeAsUpEnabled(true); actionBar.setHomeButtonEnabled(true); - toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed()); + toolbar.setNavigationOnClickListener(v -> requireActivity().getOnBackPressedDispatcher().onBackPressed()); } initToolbarOptions(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java index 9cab5c01e0..15d6a8c7a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java @@ -176,7 +176,7 @@ private void initToolbar(Toolbar toolbar) { actionBar.setDisplayHomeAsUpEnabled(true); actionBar.setHomeButtonEnabled(true); - toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed()); + toolbar.setNavigationOnClickListener(v -> requireActivity().getOnBackPressedDispatcher().onBackPressed()); } private void initMediaObserver(@NonNull MediaSendViewModel viewModel) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt index aaedc5e497..67956e8753 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt @@ -13,6 +13,8 @@ import android.view.animation.DecelerateInterpolator import android.view.animation.OvershootInterpolator import android.view.animation.ScaleAnimation import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.core.view.ViewGroupCompat import androidx.fragment.app.Fragment @@ -33,6 +35,7 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.mediasend.CameraXActivity.Companion.KEY_MEDIA_SEND_COUNT import org.thoughtcrime.securesms.mediasend.MediaSendViewModel.CountButtonState import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.scribbles.ImageEditorFragment @@ -50,7 +53,8 @@ import javax.inject.Inject @AndroidEntryPoint class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragment.Controller, MediaPickerItemFragment.Controller, MediaSendFragment.Controller, - ImageEditorFragment.Controller, CameraXFragment.Controller{ + ImageEditorFragment.Controller { + private var recipient: Recipient? = null private val viewModel: MediaSendViewModel by viewModels() @@ -59,6 +63,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme @Inject lateinit var recipientRepository: RecipientRepository + private var lastEntryFromCameraCapture: Boolean = false override val applyDefaultWindowInsets: Boolean get() = false // we want to handle window insets manually here for fullscreen fragments like the camera screen @@ -74,44 +79,49 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme ViewGroupCompat.installCompatInsetsDispatch(it.root) } - setResult(RESULT_CANCELED) + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + handleBackPressedCompat() + } + }) - if (savedInstanceState != null) { - return - } + setResult(RESULT_CANCELED) // Apply windowInsets for our own UI (not the fragment ones because they will want to do their own things) binding.mediasendBottomBar.applySafeInsetsPaddings() - recipient = recipientRepository.getRecipientSync(fromSerialized( - intent.getStringExtra(KEY_ADDRESS)!! - )) + recipient = recipientRepository.getRecipientSync( + fromSerialized( + intent.getStringExtra(KEY_ADDRESS)!! + ) + ) viewModel.onBodyChanged(intent.getStringExtra(KEY_BODY)!!) - val media: List? = intent.getParcelableArrayListExtra(KEY_MEDIA) - val isCamera = intent.getBooleanExtra(KEY_IS_CAMERA, false) + if (savedInstanceState == null) { + val media: List? = intent.getParcelableArrayListExtra(KEY_MEDIA) + val isCamera = intent.getBooleanExtra(KEY_IS_CAMERA, false) - if (isCamera) { - val fragment: Fragment = CameraXFragment() - supportFragmentManager.beginTransaction() - .replace(R.id.mediasend_fragment_container, fragment, TAG_CAMERA) - .commit() - } else if (!isEmpty(media)) { - viewModel.onSelectedMediaChanged(this, media!!) + if (isCamera) { + navigateToCamera() + } else if (!isEmpty(media)) { + viewModel.onSelectedMediaChanged(this, media!!) - val fragment: Fragment = MediaSendFragment.newInstance(recipient!!.address) + lastEntryFromCameraCapture = false - supportFragmentManager.beginTransaction() - .replace(R.id.mediasend_fragment_container, fragment, TAG_SEND) - .commit() - } else { - val fragment = MediaPickerFolderFragment.newInstance( - recipient!! - ) - supportFragmentManager.beginTransaction() - .replace(R.id.mediasend_fragment_container, fragment, TAG_FOLDER_PICKER) - .commit() + val fragment: Fragment = MediaSendFragment.newInstance(recipient!!.address) + + supportFragmentManager.beginTransaction() + .replace(R.id.mediasend_fragment_container, fragment, TAG_SEND) + .commit() + } else { + val fragment = MediaPickerFolderFragment.newInstance( + recipient!! + ) + supportFragmentManager.beginTransaction() + .replace(R.id.mediasend_fragment_container, fragment, TAG_FOLDER_PICKER) + .commit() + } } initializeCountButtonObserver() @@ -129,15 +139,29 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme } } - override fun onBackPressed() { - super.onBackPressed() + private fun handleBackPressedCompat() { + val fm = supportFragmentManager + val isCameraFlow = intent.getBooleanExtra(KEY_IS_CAMERA, false) + + // Special case: we just came from camera, in camera-first flow, + // and we're on the editor as the only fragment. + if (lastEntryFromCameraCapture && isCameraFlow && fm.backStackEntryCount == 1) { + fm.popBackStackImmediate() // remove the editor fragment + viewModel.onImageCaptureUndo(this@MediaSendActivity) + lastEntryFromCameraCapture = false + navigateToCamera() + return + } - if (intent.getBooleanExtra( - KEY_IS_CAMERA, - false - ) && supportFragmentManager.backStackEntryCount == 0 - ) { - viewModel.onImageCaptureUndo(this) + // Otherwise: normal fragment back behaviour + if (fm.backStackEntryCount > 0) { + fm.popBackStack() + } else { + // Root of the activity + if (isCameraFlow) { + setResult(RESULT_CANCELED, Intent()) + } + finish() } } @@ -173,8 +197,9 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme override fun onMediaSelected(media: Media) { try { viewModel.onSingleMediaSelected(this, media) + lastEntryFromCameraCapture = false navigateToMediaSend(recipient!!.address) - } catch (e: Exception){ + } catch (e: Exception) { Log.e(TAG, "Error selecting media", e) Toast.makeText(this, R.string.errorUnknown, Toast.LENGTH_LONG).show() } @@ -234,67 +259,30 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme fragment?.onTouchEventsNeeded(needed) } - override fun onCameraError() { - lifecycleScope.launch { - Toast.makeText(applicationContext, R.string.cameraErrorUnavailable, Toast.LENGTH_SHORT).show() - setResult(RESULT_CANCELED, Intent()) - finish() - } - } - - override fun onImageCaptured(imageUri: Uri, size: Long, width: Int, height: Int) { - Log.i(TAG, "Camera image captured.") - SimpleTask.run(lifecycle, { - try { - return@run Media( - imageUri, - constructPhotoFilename(this), - MediaTypes.IMAGE_JPEG, - System.currentTimeMillis(), - width, - height, - size, - Media.ALL_MEDIA_BUCKET_ID, - null - ) - } catch (e: Exception) { - return@run null - } - }, { media: Media? -> - if (media == null) { - onNoMediaAvailable() - return@run - } - Log.i(TAG, "Camera capture stored: " + media.uri.toString()) - - viewModel.onImageCaptured(media) - navigateToMediaSend(recipient!!.address) - }) - } - private fun initializeCountButtonObserver() { viewModel.getCountButtonState().observe( this ) { buttonState: CountButtonState? -> if (buttonState == null) return@observe - binding.mediasendCountButtonText.text = buttonState.count.toString() - binding.mediasendCountButton.isEnabled = buttonState.isVisible + binding.mediasendCountContainer.mediasendCountButtonText.text = buttonState.count.toString() + binding.mediasendCountContainer.mediasendCountButton.isEnabled = buttonState.isVisible animateButtonVisibility( - binding.mediasendCountButton, - binding.mediasendCountButton.visibility, + binding.mediasendCountContainer.mediasendCountButton, + binding.mediasendCountContainer.mediasendCountButton.visibility, if (buttonState.isVisible) View.VISIBLE else View.GONE ) if (buttonState.count > 0) { - binding.mediasendCountButton.setOnClickListener { v: View? -> + binding.mediasendCountContainer.mediasendCountButton.setOnClickListener { v: View? -> + lastEntryFromCameraCapture = false navigateToMediaSend( recipient!!.address ) } if (buttonState.isVisible) { - animateButtonTextChange(binding.mediasendCountButton) + animateButtonTextChange(binding.mediasendCountContainer.mediasendCountButton) } } else { - binding.mediasendCountButton.setOnClickListener(null) + binding.mediasendCountContainer.mediasendCountButton.setOnClickListener(null) } } } @@ -384,21 +372,10 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme .request(Manifest.permission.CAMERA) .withPermanentDenialDialog(permanentDenialTxt) .onAllGranted { - val fragment = orCreateCameraFragment - supportFragmentManager.beginTransaction() - .setCustomAnimations( - R.anim.slide_from_right, - R.anim.slide_to_left, - R.anim.slide_from_left, - R.anim.slide_to_right - ) - .replace( - R.id.mediasend_fragment_container, - fragment, - TAG_CAMERA - ) - .addToBackStack(null) - .commit() + val countNow = viewModel.getCountButtonState().value?.count ?: 0 + val intent = Intent(this@MediaSendActivity, CameraXActivity::class.java) + .putExtra(KEY_MEDIA_SEND_COUNT, countNow) + cameraLauncher.launch(intent) viewModel.onCameraStarted() } @@ -412,14 +389,6 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme .execute() } - private val orCreateCameraFragment: CameraXFragment - get() { - val fragment = - supportFragmentManager.findFragmentByTag(TAG_CAMERA) as CameraXFragment? - - return fragment ?: CameraXFragment() - } - private fun animateButtonVisibility(button: View, oldVisibility: Int, newVisibility: Int) { if (oldVisibility == newVisibility) return @@ -511,6 +480,74 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme } } + private val cameraLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK && result.data != null) { + val data = result.data!! + + val uriString = data.getStringExtra(CameraXActivity.EXTRA_IMAGE_URI) + val size = data.getLongExtra(CameraXActivity.EXTRA_IMAGE_SIZE, -1L) + val width = data.getIntExtra(CameraXActivity.EXTRA_IMAGE_WIDTH, -1) + val height = data.getIntExtra(CameraXActivity.EXTRA_IMAGE_HEIGHT, -1) + + val uri = uriString?.let { Uri.parse(it) } + + if (uri == null || size <= 0 || width <= 0 || height <= 0) { + handleCameraError() + } else { + handleCameraImageCaptured(uri, size, width, height) + } + }else{ + if(supportFragmentManager.backStackEntryCount == 0){ + finish() + } + } + } + + private fun handleCameraError() { + lifecycleScope.launch { + Toast.makeText(applicationContext, R.string.cameraErrorUnavailable, Toast.LENGTH_SHORT) + .show() + setResult(RESULT_CANCELED, Intent()) + finish() + } + } + + private fun handleCameraImageCaptured(imageUri: Uri, size: Long, width: Int, height: Int) { + Log.i(TAG, "Camera image captured.") + SimpleTask.run(lifecycle, { + try { + return@run Media( + imageUri, + constructPhotoFilename(this), + MediaTypes.IMAGE_JPEG, + System.currentTimeMillis(), + width, + height, + size, + Media.ALL_MEDIA_BUCKET_ID, + null + ) + } catch (e: Exception) { + Log.e(TAG, "Error constructing Media from camera result", e) + return@run null + } + }, { media: Media? -> + if (media == null) { + onNoMediaAvailable() + return@run + } + Log.i(TAG, "Camera capture stored: ${media.uri}") + viewModel.onImageCaptured(media) + lastEntryFromCameraCapture = true + navigateToMediaSend(recipient!!.address) + }) + } + + override fun onDestroy() { + super.onDestroy() + } + companion object { private val TAG: String = MediaSendActivity::class.java.simpleName @@ -564,3 +601,4 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme } } } + diff --git a/app/src/main/java/org/thoughtcrime/securesms/migration/DatabaseMigrationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/migration/DatabaseMigrationScreen.kt index b04e8754f3..366e61b03f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migration/DatabaseMigrationScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/migration/DatabaseMigrationScreen.kt @@ -18,8 +18,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.retain.retain import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,10 +40,10 @@ import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.components.OutlineButton import org.thoughtcrime.securesms.ui.components.AccentFillButton import org.thoughtcrime.securesms.ui.components.ExportLogsDialog import org.thoughtcrime.securesms.ui.components.LogExporter +import org.thoughtcrime.securesms.ui.components.OutlineButton import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -59,7 +59,7 @@ fun DatabaseMigrationScreen( fm: FragmentManager, ) { val scope = rememberCoroutineScope() - var showExportLogDialog by remember { mutableStateOf(false) } + var showExportLogDialog by retain { mutableStateOf(false) } DatabaseMigration( state = migrationManager.migrationState.collectAsState().value, @@ -101,8 +101,8 @@ private fun DatabaseMigration( onClearData: () -> Unit = {}, onClearDataWithoutLoggingOut: () -> Unit = {}, ) { - var showingClearDeviceRestoreWarning by remember { mutableStateOf(false) } - var showingClearDeviceRestartWarning by remember { mutableStateOf(false) } + var showingClearDeviceRestoreWarning by retain { mutableStateOf(false) } + var showingClearDeviceRestartWarning by retain { mutableStateOf(false) } Surface( color = LocalColors.current.background, diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt index 5362632d4a..d2c589a1b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.retain.retain import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -70,7 +71,7 @@ internal fun LandingScreen( var count by remember { mutableStateOf(0) } val listState = rememberLazyListState() - var isUrlDialogVisible by remember { mutableStateOf(false) } + var isUrlDialogVisible by retain { mutableStateOf(false) } if (isUrlDialogVisible) { TCPolicyDialog( @@ -181,7 +182,7 @@ internal fun LandingScreen( @Composable private fun AnimateMessageText(text: String, isOutgoing: Boolean, modifier: Modifier = Modifier) { - var visible by remember { mutableStateOf(false) } + var visible by retain { mutableStateOf(false) } LaunchedEffect(Unit) { visible = true } Box { diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsActivity.kt index 9ea4fd2bd6..abab1f68c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsActivity.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.onboarding.messagenotifications import android.app.Activity import android.os.Bundle +import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -45,6 +46,13 @@ class MessageNotificationsActivity : BaseActionBarActivity() { setComposeContent { MessageNotificationsScreen() } + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (viewModel.onBackPressed()) return + finish() + } + }) + lifecycleScope.launch { viewModel.events.collect { when (it) { @@ -55,14 +63,6 @@ class MessageNotificationsActivity : BaseActionBarActivity() { } } - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - if (viewModel.onBackPressed()) return - - @Suppress("DEPRECATION") - super.onBackPressed() - } - @Composable private fun MessageNotificationsScreen() { val uiState by viewModel.uiStates.collectAsState() diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameActivity.kt index b8018ab9e4..c84cc600a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameActivity.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.onboarding.pickname import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -39,6 +40,15 @@ class PickDisplayNameActivity : BaseActionBarActivity() { setComposeContent { DisplayNameScreen(viewModel) } + // Predictive back-firendly + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (!viewModel.onBackPressed()) { + finish() + } + } + }) + lifecycleScope.launch(Dispatchers.Main) { viewModel.events.collect { when (it) { @@ -59,14 +69,6 @@ class PickDisplayNameActivity : BaseActionBarActivity() { quit = { viewModel.dismissDialog(); finish() } ) } - - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - if (viewModel.onBackPressed()) return - - @Suppress("DEPRECATION") - super.onBackPressed() - } } fun Context.startPickDisplayNameActivity(loadFailed: Boolean = false, flags: Int = 0) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt index f9b44b4cb1..02c80c81c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt @@ -4,17 +4,26 @@ import android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP import android.os.Bundle import android.view.View import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text 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.text.style.TextAlign import dagger.hilt.android.AndroidEntryPoint +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.min import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -26,6 +35,7 @@ import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.ui.adaptive.getAdaptiveInfo import org.thoughtcrime.securesms.ui.components.QRScannerScreen import org.thoughtcrime.securesms.ui.components.QrImage import org.thoughtcrime.securesms.ui.components.SessionTabRow @@ -48,7 +58,11 @@ class QRCodeActivity : ScreenLockActionBarActivity() { override val applyDefaultWindowInsets: Boolean get() = false - private val errors = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private val errors = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) @@ -85,7 +99,11 @@ class QRCodeActivity : ScreenLockActionBarActivity() { } } - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) } @@ -95,11 +113,17 @@ class QRCodeActivity : ScreenLockActionBarActivity() { private fun Tabs(accountId: String, errors: Flow, onScan: (String) -> Unit) { val pagerState = rememberPagerState { TITLES.size } - Column { + Column( + modifier = Modifier + .fillMaxSize() + .background(LocalColors.current.background) + ) { SessionTabRow(pagerState, TITLES) HorizontalPager( state = pagerState, - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .fillMaxWidth() ) { page -> when (TITLES[page]) { R.string.view -> QrPage(accountId) @@ -111,6 +135,15 @@ private fun Tabs(accountId: String, errors: Flow, onScan: (String) -> Un @Composable fun QrPage(string: String) { + if (getAdaptiveInfo().isLandscape) { + LandscapeContent(string) + } else { + PortraitContent(string) + } +} + +@Composable +private fun PortraitContent(string: String) { Column( modifier = Modifier .background(LocalColors.current.background) @@ -120,7 +153,10 @@ fun QrPage(string: String) { QrImage( string = string, modifier = Modifier - .padding(top = LocalDimensions.current.mediumSpacing, bottom = LocalDimensions.current.xsSpacing) + .padding( + top = LocalDimensions.current.mediumSpacing, + bottom = LocalDimensions.current.xsSpacing + ) .qaTag(R.string.AccessibilityId_qrCode), icon = R.drawable.session ) @@ -133,3 +169,45 @@ fun QrPage(string: String) { ) } } + +@Composable +private fun LandscapeContent(string: String) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(LocalColors.current.background) + .padding(horizontal = LocalDimensions.current.mediumSpacing) + ) { + // Scale QR to the shorter side to avoid overflow in landscape. Clamp for sanity + val shortest: Dp = min(maxWidth, maxHeight) + val qrSide = (shortest * 0.70f).coerceIn( + LocalDimensions.current.minContentSize, + LocalDimensions.current.maxContentSize + ) + + Column( + modifier = Modifier + .align(Alignment.Center) + .verticalScroll(rememberScrollState()) + .padding(vertical = LocalDimensions.current.spacing), // vertical + horizontal centering + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) + + ) { + QrImage( + string = string, + modifier = Modifier + .size(qrSide) + .qaTag(R.string.AccessibilityId_qrCode), + icon = R.drawable.session + ) + + Text( + text = stringResource(R.string.accountIdYoursDescription), + color = LocalColors.current.textSecondary, + textAlign = TextAlign.Center, + style = LocalType.current.small + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt index acadb2c13f..7fa183210c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsBottomHeight @@ -42,6 +43,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.retain.retain import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterHorizontally @@ -212,7 +214,7 @@ fun Settings( } ) }, - contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal), ) { paddings -> // MAIN SCREEN CONTENT Column( @@ -347,7 +349,7 @@ fun Settings( ) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) } // DIALOGS AND SHEETS @@ -740,7 +742,7 @@ fun ShowClearDataDialog( modifier: Modifier = Modifier, sendCommand: (SettingsViewModel.Commands) -> Unit ) { - var deleteOnNetwork by remember { mutableStateOf(false)} + var deleteOnNetwork by retain { mutableStateOf(false)} val context = LocalContext.current AlertDialog( diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt index 12c6dd8e22..aa0ba02bc3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt @@ -12,10 +12,13 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.widthIn @@ -37,6 +40,7 @@ import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -105,20 +109,24 @@ fun BaseProSettingsScreen( onBack = onBack, ) }} else {{}}, - contentWindowInsets = WindowInsets.systemBars, + contentWindowInsets = WindowInsets.safeDrawing, ) { paddings -> + + val layoutDirection = LocalLayoutDirection.current + val safeInsetsPadding = PaddingValues( + start = paddings.calculateStartPadding(layoutDirection) + LocalDimensions.current.spacing, + end = paddings.calculateEndPadding(layoutDirection)+ LocalDimensions.current.spacing, + top = (paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) + .coerceAtLeast(0.dp) + 46.dp, + bottom = paddings.calculateBottomPadding() + LocalDimensions.current.spacing + ) + LazyColumn( modifier = Modifier .fillMaxWidth() .consumeWindowInsets(paddings), state = listState, - contentPadding = PaddingValues( - start = LocalDimensions.current.spacing, - end = LocalDimensions.current.spacing, - top = (paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) - .coerceAtLeast(0.dp) + 46.dp, - bottom = paddings.calculateBottomPadding() + LocalDimensions.current.spacing - ), + contentPadding = safeInsetsPadding, horizontalAlignment = CenterHorizontally ) { item { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt index 85c4f8ee2d..cece2bb768 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.retain.retain import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.compose.NavHost @@ -66,7 +67,7 @@ fun ProSettingsNavHost( onBack: () -> Unit ){ val navController = rememberNavController() - val navigator: UINavigator = remember { + val navigator: UINavigator = retain { UINavigator() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt index 7889463fd8..8fcdd4ca1f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt @@ -2,21 +2,22 @@ package org.thoughtcrime.securesms.recoverypassword import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.widthIn -import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.retain.retain import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -31,6 +32,7 @@ import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.SessionShieldIcon +import org.thoughtcrime.securesms.ui.adaptive.getAdaptiveInfo import org.thoughtcrime.securesms.ui.border import org.thoughtcrime.securesms.ui.components.QrImage import org.thoughtcrime.securesms.ui.components.SlimOutlineButton @@ -70,7 +72,7 @@ private fun RecoveryPasswordCell( seed: String?, copyMnemonic:() -> Unit = {} ) { - var showQr by remember { + var showQr by retain { mutableStateOf(false) } @@ -104,13 +106,34 @@ private fun RecoveryPasswordCell( showQr, modifier = Modifier.align(Alignment.CenterHorizontally) ) { - QrImage( - seed, - modifier = Modifier - .padding(vertical = LocalDimensions.current.spacing) - .qaTag(R.string.AccessibilityId_qrCode), - icon = R.drawable.ic_recovery_password_custom - ) + val config = getAdaptiveInfo() + val isLandscape = config.isLandscape + + BoxWithConstraints { + val availableWidth = maxWidth + val widthTarget = availableWidth * if (isLandscape) 0.80f else 1f + val heightCap = if (isLandscape) config.heightDp.dp * 0.70f else config.heightDp.dp * 1f + + val qrSide = widthTarget + .coerceAtMost(heightCap) + .coerceIn( + LocalDimensions.current.minContentSizeMedium, + LocalDimensions.current.maxContentSizeMedium + ) + + QrImage( + seed, + modifier = Modifier + .padding( + top = LocalDimensions.current.spacing, + bottom = LocalDimensions.current.smallSpacing + ) + .size(qrSide) + .qaTag(R.string.AccessibilityId_qrCode), + icon = R.drawable.ic_recovery_password_custom + ) + + } } AnimatedVisibility(!showQr) { @@ -158,8 +181,8 @@ private fun RecoveryPassword(mnemonic: String) { private fun HideRecoveryPasswordCell( confirmHideRecovery:() -> Unit ) { - var showHideRecoveryDialog by remember { mutableStateOf(false) } - var showHideRecoveryConfirmationDialog by remember { mutableStateOf(false) } + var showHideRecoveryDialog by retain { mutableStateOf(false) } + var showHideRecoveryConfirmationDialog by retain { mutableStateOf(false) } Cell { Row( diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/StickerSelectActivity.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/StickerSelectActivity.java index 8afc241a56..d3a32b84b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/StickerSelectActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/StickerSelectActivity.java @@ -28,6 +28,8 @@ import com.google.android.material.tabs.TabLayoutMediator; +import org.thoughtcrime.securesms.util.ViewUtilitiesKt; + import network.loki.messenger.R; import network.loki.messenger.databinding.ScribbleSelectStickerActivityBinding; @@ -51,6 +53,7 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { setContentView(binding.getRoot()); binding.cameraStickerPager.setAdapter(new StickerPagerAdapter(this, this)); + ViewUtilitiesKt.applySafeInsetsPaddings(binding.getRoot()); new TabLayoutMediator( binding.cameraStickerTabs, @@ -64,7 +67,7 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == android.R.id.home) { - onBackPressed(); + getOnBackPressedDispatcher().onBackPressed(); return true; } return super.onOptionsItemSelected(item); diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt index c2805accac..ab03ce4a79 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width @@ -36,11 +38,11 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.rememberTooltipState 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.rememberCoroutineScope +import androidx.compose.runtime.retain.retain import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.TopCenter @@ -71,6 +73,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.TOKEN_NAME_S import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ui.OpenURLAlertDialog import org.thoughtcrime.securesms.ui.SpeechBubbleTooltip +import org.thoughtcrime.securesms.ui.adaptive.getAdaptiveInfo import org.thoughtcrime.securesms.ui.components.AccentOutlineButtonRect import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.BlurredImage @@ -115,7 +118,7 @@ fun TokenPage( .qaTag("Page heading") ) }, - contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal), ) { contentPadding -> PullToRefreshBox( @@ -179,7 +182,8 @@ fun TokenPage( val hasNoScroll = scrollState.maxValue == 0 || scrollState.maxValue == Int.MAX_VALUE Column( - modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing) + modifier = Modifier + .padding(horizontal = LocalDimensions.current.spacing) .then( if (hasNoScroll) { Modifier.weight(1f) @@ -246,7 +250,7 @@ fun SessionNetworkInfoSection(modifier: Modifier = Modifier) { ) // Note: We apply the link to the entire box so the user doesn't have to click exactly on the highlighted text. - var showTheOpenUrlModal by remember { mutableStateOf(false) } + var showTheOpenUrlModal by retain { mutableStateOf(false) } Text( modifier = Modifier .clickable { showTheOpenUrlModal = true } @@ -275,7 +279,6 @@ fun StatsImageBox( ) { Box( modifier = modifier - .fillMaxWidth() .aspectRatio(1.15f) .border( width = 1.dp, @@ -471,32 +474,53 @@ fun StatsSection( priceDataPopupText: String, modifier: Modifier = Modifier ) { - // First row contains the `StatsImageBox` with the number of nodes in your swap and the text - // details with that number and the number of nodes securing your messages. - Row(modifier = modifier.fillMaxWidth()) { - // On the left we have the node image showing how many nodes are in the user's swarm.. - val (linesDrawable, circlesDrawable) = getNodeImageForSwarmSize(currentSessionNodesInSwarm) - StatsImageBox( - showNodeCountsAsRefreshing = showNodeCountsAsRefreshing, - lineDrawableId = linesDrawable, - circlesDrawableId = circlesDrawable, - modifier = Modifier - .fillMaxWidth(0.45f) - .qaTag("Swarm image") - ) + val screenInfo = getAdaptiveInfo() + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + // Left pane target width = min(45% of row, height cap * aspect) + val leftMaxWidth = maxWidth * 0.45f + val aspect = 1.15f - Spacer(modifier = Modifier.width(LocalDimensions.current.xsSpacing)) + val heightCap = + if (screenInfo.isLandscape) screenInfo.heightDp.dp * 0.40f else screenInfo.heightDp.dp * 0.50f - // ..and on the right we have the text details of num nodes in swarm and total nodes securing your messages. - NodeDetailsBox( - showNodeCountsAsRefreshing = showNodeCountsAsRefreshing, - numNodesInSwarm = currentSessionNodesInSwarm.toString(), - numNodesSecuringMessages = currentSessionNodesSecuringMessages.toString(), - modifier = Modifier - .fillMaxWidth(1.0f) - .align(Alignment.CenterVertically) - ) + val targetWidth = leftMaxWidth + .coerceAtMost(heightCap * aspect) + .coerceIn( + LocalDimensions.current.minContentSizeSmall, + LocalDimensions.current.maxContentSizeSmall + ) // hard cap to keep tidy on very wide screens + + + // First row contains the `StatsImageBox` with the number of nodes in your swap and the text + // details with that number and the number of nodes securing your messages. + Row(modifier = modifier.fillMaxWidth()) { + + // On the left we have the node image showing how many nodes are in the user's swarm.. + val (linesDrawable, circlesDrawable) = getNodeImageForSwarmSize( + currentSessionNodesInSwarm + ) + StatsImageBox( + showNodeCountsAsRefreshing = showNodeCountsAsRefreshing, + lineDrawableId = linesDrawable, + circlesDrawableId = circlesDrawable, + modifier = Modifier + .width(targetWidth) + .qaTag("Swarm image") + ) + + Spacer(modifier = Modifier.width(LocalDimensions.current.xsSpacing)) + + // ..and on the right we have the text details of num nodes in swarm and total nodes securing your messages. + NodeDetailsBox( + showNodeCountsAsRefreshing = showNodeCountsAsRefreshing, + numNodesInSwarm = currentSessionNodesInSwarm.toString(), + numNodesSecuringMessages = currentSessionNodesSecuringMessages.toString(), + modifier = Modifier + .fillMaxWidth(1.0f) + .align(Alignment.CenterVertically) + ) + } } Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) @@ -577,7 +601,8 @@ fun StatsSection( setTwoLineTwo, setTwoLineThree, qaTag = "Network secured amount", - modifier = Modifier.fillMaxWidth(1.0f) + modifier = Modifier + .fillMaxWidth(1.0f) .onGloballyPositioned { coordinates -> // Calculate this cell's height in dp val heightInDp = with(density) { coordinates.size.height.toDp() } @@ -702,7 +727,7 @@ fun SessionTokenSection( Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) // Finally, add a button that links us to the staging page to learn more - var showTheOpenUrlModal by remember { mutableStateOf(false) } + var showTheOpenUrlModal by retain { mutableStateOf(false) } AccentOutlineButtonRect( text = LocalContext.current.getString(R.string.sessionNetworkLearnAboutStaking), modifier = Modifier diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 7ec5667cb6..c1b830e291 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -76,13 +76,13 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.retain.retain import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.draw.rotate import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.onFocusChanged @@ -100,7 +100,6 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager @@ -119,7 +118,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times import kotlinx.coroutines.CoroutineScope @@ -1164,8 +1162,8 @@ fun ExpandableText( expandButtonText: String = stringResource(id = R.string.viewMore), collapseButtonText: String = stringResource(id = R.string.viewLess), ) { - var expanded by remember { mutableStateOf(false) } - var showButton by remember { mutableStateOf(false) } + var expanded by retain { mutableStateOf(false) } + var showButton by retain { mutableStateOf(false) } var maxHeight by remember { mutableStateOf(Dp.Unspecified) } val density = LocalDensity.current diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Modifiers.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Modifiers.kt index bb7ecb5716..01ffc16516 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Modifiers.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Modifiers.kt @@ -16,11 +16,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.MaterialTheme 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.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.drawWithCache @@ -48,7 +44,6 @@ import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -import kotlinx.coroutines.delay import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/adaptive/AdaptiveLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/adaptive/AdaptiveLayout.kt new file mode 100644 index 0000000000..22f1c64567 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/adaptive/AdaptiveLayout.kt @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.ui.adaptive + +import android.annotation.SuppressLint +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.platform.LocalConfiguration + +/** + * Immutable, @Stable container for adaptive layout info. + * Safe to hoist and pass to child composables without causing unnecessary recompositions. + */ +@Stable +data class AdaptiveInfo( + val widthDp: Int, + val heightDp: Int, + val isLandscape: Boolean +) + +/** + * Returns a stable snapshot of the window/adaptive state for the current composition. + * Currently we use this for landscape + */ +@SuppressLint("ConfigurationScreenWidthHeight") +@Composable +fun getAdaptiveInfo(): AdaptiveInfo { + val configuration = LocalConfiguration.current + val landscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + return AdaptiveInfo(configuration.screenWidthDp, configuration.screenHeightDp, landscape) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt index 220fabdbf0..cad643778f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt @@ -12,14 +12,18 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState @@ -107,7 +111,8 @@ fun ConversationAppBar( if (data.pagerData.isNotEmpty()) { // Settings content pager ConversationSettingsPager( - modifier = Modifier.padding(top = 2.dp) + modifier = Modifier + .padding(top = 2.dp) .fillMaxWidth(0.8f), pages = data.pagerData, pagerState = pagerState @@ -142,14 +147,18 @@ fun ConversationAppBar( // Avatar if (data.showAvatar) { Avatar( - modifier = Modifier.qaTag(R.string.qa_conversation_avatar) + modifier = Modifier + .qaTag(R.string.qa_conversation_avatar) .padding( - start = if(data.showCall) 0.dp else LocalDimensions.current.xsSpacing, + start = if (data.showCall) 0.dp else LocalDimensions.current.xsSpacing, end = LocalDimensions.current.xsSpacing ) .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = ripple(bounded = false, radius = LocalDimensions.current.iconLargeAvatar/2), + indication = ripple( + bounded = false, + radius = LocalDimensions.current.iconLargeAvatar / 2 + ), onClick = onAvatarPressed ), size = LocalDimensions.current.iconLargeAvatar, @@ -164,7 +173,11 @@ fun ConversationAppBar( true -> { Row( modifier = Modifier - .statusBarsPadding() + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Top + WindowInsetsSides.Horizontal + ) + ) .padding(horizontal = LocalDimensions.current.smallSpacing) .heightIn(min = LocalDimensions.current.appBarHeight), verticalAlignment = Alignment.CenterVertically, @@ -180,7 +193,8 @@ fun ConversationAppBar( onValueChanged = onSearchQueryChanged, onClear = onSearchQueryClear, placeholder = stringResource(R.string.search), - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) .focusRequester(focusRequester), backgroundColor = LocalColors.current.backgroundSecondary, ) @@ -188,7 +202,8 @@ fun ConversationAppBar( Spacer(Modifier.width(LocalDimensions.current.xsSpacing)) Text( - modifier = Modifier.qaTag(R.string.qa_conversation_search_cancel) + modifier = Modifier + .qaTag(R.string.qa_conversation_search_cancel) .clickable { onSearchCanceled() }, @@ -240,7 +255,8 @@ private fun ConversationSettingsPager( modifier = modifier, ) { page -> Row ( - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .qaTag(pages[page].qaTag ?: pages[page].title) .clickable { pages[page].action() @@ -281,7 +297,9 @@ private fun ConversationSettingsPager( // '>' icon if(pages.size > 1) { Image( - modifier = Modifier.size(12.dp).rotate(180f), + modifier = Modifier + .size(12.dp) + .rotate(180f), painter = painterResource(id = R.drawable.ic_chevron_left), colorFilter = ColorFilter.tint(LocalColors.current.text), contentDescription = null, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt index 47ecd00418..1807c34ccd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.retain.retain import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -52,7 +53,7 @@ fun DropDown( labeler: (T?) -> String, allowSelectingNullValue: Boolean ) { - var expanded by remember { mutableStateOf(false) } + var expanded by retain { mutableStateOf(false) } ExposedDropdownMenuBox( modifier = modifier, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt index a0454ff985..19a7d991e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.retain.retain import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -101,7 +102,7 @@ fun QRScannerScreen( val permission = Manifest.permission.CAMERA val cameraPermissionState = rememberPermissionState(permission) - var showCameraPermissionDialog by remember { mutableStateOf(false) } + var showCameraPermissionDialog by retain { mutableStateOf(false) } if (cameraPermissionState.status.isGranted) { ScanQrCode(errors, onScan) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionTabRow.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionTabRow.kt index 632cb49b0c..c66abaf0f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionTabRow.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionTabRow.kt @@ -30,26 +30,65 @@ private val TITLES = listOf(R.string.sessionRecoveryPassword, R.string.qrScan) @OptIn(ExperimentalFoundationApi::class) @Composable -fun SessionTabRow(pagerState: PagerState, titles: List) { +fun SessionTabRow( + pagerState: PagerState, + titles: List +) { + val animationScope = rememberCoroutineScope() + BaseSessionTabRow( + selectedIndex = pagerState.currentPage, + titles = titles, + onTabClick = { i -> + animationScope.launch { pagerState.animateScrollToPage(i) } + } + ) +} + +/** For ViewPager2 integration + * I created this initially as a workaround for GiphyTabs, + * which requires quite a lot of changes to be a fully composable screen. + * + * Also marked this for deletion once the screens are fully composable. + * */ +@Deprecated("To be deleted when screens that use viewpager2 are refactored to HorizontalPager") +@Composable +fun SessionTabRow( + selectedIndex: Int, + titles: List, + onTabSelected: (Int) -> Unit +) { + BaseSessionTabRow( + selectedIndex = selectedIndex.coerceIn(0, titles.lastIndex), + titles = titles, + onTabClick = onTabSelected + ) +} + +/** Shared implementation */ +@Composable +private fun BaseSessionTabRow( + selectedIndex: Int, + titles: List, + onTabClick: (Int) -> Unit +) { TabRow( - containerColor = Color.Unspecified, - selectedTabIndex = pagerState.currentPage, - contentColor = LocalColors.current.text, - indicator = { tabPositions -> - TabRowDefaults.SecondaryIndicator( - Modifier.tabIndicatorOffset(tabPositions[pagerState.currentPage]), - color = LocalColors.current.accent, - height = LocalDimensions.current.indicatorHeight - ) - }, - divider = { HorizontalDivider(color = LocalColors.current.borders) } + containerColor = Color.Unspecified, + selectedTabIndex = selectedIndex, + contentColor = LocalColors.current.text, + indicator = { tabPositions -> + TabRowDefaults.SecondaryIndicator( + Modifier.tabIndicatorOffset(tabPositions[selectedIndex]), + color = LocalColors.current.accent, + height = LocalDimensions.current.indicatorHeight + ) + }, + divider = { HorizontalDivider(color = LocalColors.current.borders) } ) { - val animationScope = rememberCoroutineScope() titles.forEachIndexed { i, it -> Tab( modifier = Modifier.heightIn(min = 48.dp), - selected = i == pagerState.currentPage, - onClick = { animationScope.launch { pagerState.animateScrollToPage(i) } }, + selected = i == selectedIndex, + onClick = { onTabClick(i) }, selectedContentColor = LocalColors.current.text, unselectedContentColor = LocalColors.current.text, ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt index 21f4f410e9..18746d21f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt @@ -46,4 +46,11 @@ data class Dimensions( val maxContentWidth: Dp = 410.dp, val maxDialogWidth: Dp = 560.dp, val maxTooltipWidth: Dp = 280.dp, + + val minContentSizeSmall: Dp = 60.dp, + val maxContentSizeSmall: Dp = 420.dp, + val minContentSize: Dp = 80.dp, + val maxContentSize: Dp = 520.dp, + val minContentSizeMedium: Dp = 160.dp, + val maxContentSizeMedium: Dp = 620.dp ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt index 9703ab1bf1..a65588d413 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt @@ -119,7 +119,7 @@ fun EditText.addTextChangedListener(listener: (String) -> Unit) { @JvmOverloads fun View.applySafeInsetsPaddings( @InsetsType - typeMask: Int = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime(), + typeMask: Int = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.displayCutout() , consumeInsets: Boolean = true, applyTop: Boolean = true, applyBottom: Boolean = true, @@ -154,7 +154,7 @@ fun View.applySafeInsetsMargins( consumeInsets: Boolean = true, @InsetsType typeMask: Int = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime(), - additionalInsets : Insets = Insets.NONE // for additional offsets + additionalInsets : Insets = Insets.NONE, // for additional offsets ) { ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> // Get system bars insets @@ -163,7 +163,12 @@ fun View.applySafeInsetsMargins( // Update view margins to account for system bars val lp = view.layoutParams as? MarginLayoutParams if (lp != null) { - lp.setMargins(additionalInsets.left + systemBarsInsets.left, additionalInsets.top + systemBarsInsets.top, additionalInsets.right + systemBarsInsets.right, additionalInsets.bottom + systemBarsInsets.bottom) + lp.setMargins( + additionalInsets.left + systemBarsInsets.left, + additionalInsets.top + systemBarsInsets.top, + additionalInsets.right + systemBarsInsets.right, + additionalInsets.bottom + systemBarsInsets.bottom + ) view.layoutParams = lp if (consumeInsets) { @@ -179,6 +184,28 @@ fun View.applySafeInsetsMargins( } } +/** + * Independent helper for applying inset safe bottom margin to + * so we don't contradict [applySafeInsetsMargins] with apply* flags + */ +fun View.applyBottomInsetMargin( + @InsetsType typeMask: Int = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime(), + extraBottom: Int = 0, + consumeInsets: Boolean = true +) { + ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> + val insets = windowInsets.getInsets(typeMask) + val lp = view.layoutParams as? MarginLayoutParams + + if (lp != null) { + lp.bottomMargin = insets.bottom + extraBottom + view.layoutParams = lp + } + + if (consumeInsets) WindowInsetsCompat.CONSUMED else windowInsets + } +} + /** * Applies the system insets to a RecyclerView or ScrollView. The inset will apply as margin * at the top and padding at the bottom. For ScrollView, the bottom insets will be applied to the first child. diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index 61fe3b3f5a..572c704bab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -824,9 +824,9 @@ class CallManager @Inject constructor( // apply the rotation to the streams peerConnection?.setDeviceRotation(rotation) - remoteRotationSink?.rotation = abs(rotation) // abs as we never need the remote video to be inverted + remoteRotationSink?.rotation = + abs(rotation) // abs as we never need the remote video to be inverted } - fun handleWiredHeadsetChanged(present: Boolean) { if (currentConnectionState in arrayOf(CallState.Connected, CallState.LocalRing, diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt index a712a3b876..df89f1e94a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt @@ -89,10 +89,7 @@ class CallViewModel @Inject constructor( get() = callManager.videoState var deviceOrientation: Orientation = Orientation.UNKNOWN - set(value) { - field = value - callManager.setDeviceOrientation(value) - } + set(value) { field = value } val currentCallState get() = callManager.currentCallState @@ -238,6 +235,13 @@ class CallViewModel @Inject constructor( fun hangUp() = rtcCallBridge.handleLocalHangup(null) + fun setDeviceOrientation(orientation: Orientation, autoRotateOn: Boolean) { + deviceOrientation = orientation + + if(!autoRotateOn){ + callManager.setDeviceOrientation(deviceOrientation) + } + } data class CallState( val callLabelTitle: String?, diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/OrientationManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/OrientationManager.kt index c16c9f93f5..83e0f9eefa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/OrientationManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/OrientationManager.kt @@ -44,12 +44,6 @@ class OrientationManager(private val context: Context): SensorEventListener { override fun onSensorChanged(event: SensorEvent) { if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) { - // if auto-rotate is off, bail and send UNKNOWN - if (!isAutoRotateOn()) { - _orientation.value = Orientation.UNKNOWN - return - } - // Get the quaternion from the rotation vector sensor val quaternion = FloatArray(4) SensorManager.getQuaternionFromVector(quaternion, event.values) @@ -74,7 +68,7 @@ class OrientationManager(private val context: Context): SensorEventListener { } //Function to check if Android System Auto-rotate is on or off - private fun isAutoRotateOn(): Boolean { + fun isAutoRotateOn(): Boolean { return Settings.System.getInt( context.contentResolver, Settings.System.ACCELEROMETER_ROTATION, 0 diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallActivity.kt index 105e3fdb24..c379ae5a9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallActivity.kt @@ -1,10 +1,13 @@ package org.thoughtcrime.securesms.webrtc import android.Manifest +import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.content.pm.ActivityInfo import android.content.res.ColorStateList +import android.content.res.Configuration import android.graphics.Outline import android.media.AudioManager import android.os.Build @@ -15,9 +18,12 @@ import android.view.ViewOutlineProvider import android.view.WindowManager import androidx.activity.viewModels import androidx.compose.runtime.collectAsState +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet import androidx.core.content.IntentCompat import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope +import androidx.transition.TransitionManager import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -79,6 +85,10 @@ class WebRtcCallActivity : ScreenLockActionBarActivity() { */ private var orientationManager = OrientationManager(this) + private val portraitConstraints = ConstraintSet() + private val landscapeConstraints = ConstraintSet() + private lateinit var rootConstraintLayout: ConstraintLayout + override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == android.R.id.home) { finish() @@ -92,12 +102,20 @@ class WebRtcCallActivity : ScreenLockActionBarActivity() { handleIntent(intent) } + @SuppressLint("SourceLockedOrientationActivity") override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { super.onCreate(savedInstanceState, ready) binding = ActivityWebrtcBinding.inflate(layoutInflater) setContentView(binding.root) + rootConstraintLayout = binding.root + + // 1) Portrait constraints: from a portrait layout + portraitConstraints.clone(this, R.layout.activity_webrtc_portrait_template) + // 2) Landscape constraints: cloned from a template XML + landscapeConstraints.clone(this, R.layout.activity_webrtc_landscape_template) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { setShowWhenLocked(true) setTurnScreenOn(true) @@ -154,8 +172,11 @@ class WebRtcCallActivity : ScreenLockActionBarActivity() { lifecycleScope.launch { orientationManager.orientation.collect { orientation -> - viewModel.deviceOrientation = orientation - updateControlsRotation() + viewModel.setDeviceOrientation(orientation, orientationManager.isAutoRotateOn()) + if(!orientationManager.isAutoRotateOn()){ + // let system handle it + updateControlsRotation() + } } } @@ -243,12 +264,13 @@ class WebRtcCallActivity : ScreenLockActionBarActivity() { } private fun updateControlsRotation() { - with (binding) { - val rotation = when(viewModel.deviceOrientation){ - Orientation.LANDSCAPE -> -90f - Orientation.REVERSED_LANDSCAPE -> 90f - else -> 0f - } + with(binding) { + val rotation = + when (viewModel.deviceOrientation) { + Orientation.LANDSCAPE -> -90f + Orientation.REVERSED_LANDSCAPE -> 90f + else -> 0f + } userAvatar.animate().cancel() userAvatar.animate().rotation(rotation).start() @@ -269,6 +291,9 @@ class WebRtcCallActivity : ScreenLockActionBarActivity() { endCallButton.animate().cancel() endCallButton.animate().rotation(rotation).start() + + backArrow.animate().cancel() + backArrow.animate().rotation(rotation).start() } } @@ -358,54 +383,58 @@ class WebRtcCallActivity : ScreenLockActionBarActivity() { // handle video state launch { viewModel.videoState.collect { state -> - binding.floatingRenderer.removeAllViews() - binding.fullscreenRenderer.removeAllViews() - - // handle fullscreen video window - if(state.showFullscreenVideo()){ - viewModel.fullscreenRenderer?.let { surfaceView -> - binding.fullscreenRenderer.addView(surfaceView) - binding.fullscreenRenderer.isVisible = true - hideAvatar() - } - } else { - binding.fullscreenRenderer.isVisible = false - showAvatar(state.swapped) - } - - // handle floating video window - if(state.showFloatingVideo()){ - viewModel.floatingRenderer?.let { surfaceView -> - binding.floatingRenderer.addView(surfaceView) - binding.floatingRenderer.isVisible = true - binding.swapViewIcon.bringToFront() - } - } else { - binding.floatingRenderer.isVisible = false - } - - // the floating video inset (empty or not) should be shown - // the moment we have either of the video streams - val showFloatingContainer = state.userVideoEnabled || state.remoteVideoEnabled - binding.floatingRendererContainer.isVisible = showFloatingContainer - binding.swapViewIcon.isVisible = showFloatingContainer - - // make sure to default to the contact's avatar if the floating container is not visible - if (!showFloatingContainer) showAvatar(false) - - // handle buttons - binding.enableCameraButton.isSelected = state.userVideoEnabled - binding.switchCameraButton.isEnabled = state.userVideoEnabled - binding.switchCameraButton.imageTintList = - ColorStateList.valueOf( - if(state.userVideoEnabled) buttonColorEnabled - else buttonColorDisabled - ) + renderVideoState(state) } } } } + private fun renderVideoState(state : VideoState){ + binding.floatingRenderer.removeAllViews() + binding.fullscreenRenderer.removeAllViews() + + // handle fullscreen video window + if(state.showFullscreenVideo()){ + viewModel.fullscreenRenderer?.let { surfaceView -> + binding.fullscreenRenderer.addView(surfaceView) + binding.fullscreenRenderer.isVisible = true + hideAvatar() + } + } else { + binding.fullscreenRenderer.isVisible = false + showAvatar(state.swapped) + } + + // handle floating video window + if(state.showFloatingVideo()){ + viewModel.floatingRenderer?.let { surfaceView -> + binding.floatingRenderer.addView(surfaceView) + binding.floatingRenderer.isVisible = true + binding.swapViewIcon.bringToFront() + } + } else { + binding.floatingRenderer.isVisible = false + } + + // the floating video inset (empty or not) should be shown + // the moment we have either of the video streams + val showFloatingContainer = state.userVideoEnabled || state.remoteVideoEnabled + binding.floatingRendererContainer.isVisible = showFloatingContainer + binding.swapViewIcon.isVisible = showFloatingContainer + + // make sure to default to the contact's avatar if the floating container is not visible + if (!showFloatingContainer) showAvatar(false) + + // handle buttons + binding.enableCameraButton.isSelected = state.userVideoEnabled + binding.switchCameraButton.isEnabled = state.userVideoEnabled + binding.switchCameraButton.imageTintList = + ColorStateList.valueOf( + if(state.userVideoEnabled) buttonColorEnabled + else buttonColorDisabled + ) + } + /** * Shows the avatar image. * If @showUserAvatar is true, the user's avatar is shown, otherwise the contact's avatar is shown. @@ -426,4 +455,21 @@ class WebRtcCallActivity : ScreenLockActionBarActivity() { binding.fullscreenRenderer.removeAllViews() binding.floatingRenderer.removeAllViews() } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + + if (!::rootConstraintLayout.isInitialized) return + + val isLandscape = newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE + + if (isLandscape) { + landscapeConstraints.applyTo(rootConstraintLayout) + } else { + portraitConstraints.applyTo(rootConstraintLayout) + } + + updateControls(viewModel.callState.value) + renderVideoState(viewModel.videoState.value) + } } \ No newline at end of file diff --git a/app/src/main/res/layout-land/activity_camerax.xml b/app/src/main/res/layout-land/activity_camerax.xml new file mode 100644 index 0000000000..395f0b31a0 --- /dev/null +++ b/app/src/main/res/layout-land/activity_camerax.xml @@ -0,0 +1,72 @@ + + + + + + + +