From 5c3c2961fcae0ea1169c1b13e653b8c7617faf48 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 18 Dec 2025 12:11:42 +0800 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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 }