mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-28 07:51:42 +01:00
Update sticker pack preview.
This commit is contained in:
@@ -206,7 +206,7 @@
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".stickers.preview.StickerPackPreviewActivity"
|
||||
android:name=".stickers.preview.StickerPackPreviewActivityV2"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
|
||||
+4
-3
@@ -57,7 +57,6 @@ import androidx.annotation.MainThread
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
@@ -267,6 +266,8 @@ import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.Quote
|
||||
import org.thoughtcrime.securesms.database.model.StickerPackId
|
||||
import org.thoughtcrime.securesms.database.model.StickerPackKey
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.databinding.V2ConversationBackgroundBinding
|
||||
import org.thoughtcrime.securesms.databinding.V2ConversationFragmentBinding
|
||||
@@ -356,7 +357,7 @@ import org.thoughtcrime.securesms.stickers.StickerEventListener
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent
|
||||
import org.thoughtcrime.securesms.stickers.manage.StickerManagementScreen
|
||||
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewActivity
|
||||
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewActivityV2
|
||||
import org.thoughtcrime.securesms.stories.StoryViewerArgs
|
||||
import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity
|
||||
import org.thoughtcrime.securesms.util.BubbleUtil
|
||||
@@ -3693,7 +3694,7 @@ class ConversationFragment :
|
||||
|
||||
override fun onStickerClicked(stickerLocator: StickerLocator) {
|
||||
context ?: return
|
||||
startActivity(StickerPackPreviewActivity.getIntent(stickerLocator.packId, stickerLocator.packKey))
|
||||
startActivity(StickerPackPreviewActivityV2.createIntent(StickerPackId(stickerLocator.packId), StickerPackKey(stickerLocator.packKey)))
|
||||
}
|
||||
|
||||
override fun onViewOnceMessageClicked(messageRecord: MmsMessageRecord) {
|
||||
|
||||
@@ -58,13 +58,15 @@ data class StickerPackParams(
|
||||
companion object {
|
||||
fun fromExternalUri(uri: Uri?): StickerPackParams? {
|
||||
if (uri == null) return null
|
||||
return (StickerUrl.parseActionUri(uri) ?: StickerUrl.parseShareLink(uri.toString()))
|
||||
.map { parseResult ->
|
||||
StickerPackParams(
|
||||
id = StickerPackId(parseResult.first),
|
||||
key = StickerPackKey(parseResult.second)
|
||||
)
|
||||
}.orNull()
|
||||
|
||||
val parseResult = StickerUrl.parseActionUri(uri).orNull()
|
||||
?: StickerUrl.parseShareLink(uri.toString()).orNull()
|
||||
?: return null
|
||||
|
||||
return StickerPackParams(
|
||||
id = StickerPackId(parseResult.first),
|
||||
key = StickerPackKey(parseResult.second)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ import org.thoughtcrime.securesms.database.model.StickerPackId
|
||||
import org.thoughtcrime.securesms.database.model.StickerPackKey
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareArgs
|
||||
import org.thoughtcrime.securesms.stickers.StickerUrl
|
||||
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewActivity
|
||||
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewActivityV2
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
/**
|
||||
@@ -101,6 +101,6 @@ class StickerManagementActivity : PassphraseRequiredActivity() {
|
||||
}
|
||||
|
||||
private fun navigateToStickerPreview(packId: StickerPackId, packKey: StickerPackKey) {
|
||||
startActivity(StickerPackPreviewActivity.getIntent(packId.value, packKey.value))
|
||||
startActivity(StickerPackPreviewActivityV2.createIntent(packId, packKey))
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -37,7 +37,7 @@ import org.thoughtcrime.securesms.database.model.StickerPackId
|
||||
import org.thoughtcrime.securesms.database.model.StickerPackKey
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareArgs
|
||||
import org.thoughtcrime.securesms.stickers.StickerUrl
|
||||
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewActivity
|
||||
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewActivityV2
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
/**
|
||||
@@ -153,6 +153,6 @@ class StickerManagementBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
}
|
||||
|
||||
private fun navigateToStickerPreview(packId: StickerPackId, packKey: StickerPackKey) {
|
||||
startActivity(StickerPackPreviewActivity.getIntent(packId.value, packKey.value))
|
||||
startActivity(StickerPackPreviewActivityV2.createIntent(packId, packKey))
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.stickers.preview
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.StickerPackParams
|
||||
|
||||
/**
|
||||
* Actions that happen in [StickerPackPreviewActivityV2]
|
||||
*/
|
||||
sealed interface StickerPackPreviewAction {
|
||||
data class SendPack(val params: StickerPackParams) : StickerPackPreviewAction
|
||||
data class ShareExternally(val params: StickerPackParams) : StickerPackPreviewAction
|
||||
data object LinkCopied : StickerPackPreviewAction
|
||||
data object PackUnavailable : StickerPackPreviewAction
|
||||
}
|
||||
-287
@@ -1,287 +0,0 @@
|
||||
package org.thoughtcrime.securesms.stickers.preview;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Point;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs;
|
||||
import org.signal.glide.apng.ApngOptions;
|
||||
import org.signal.glide.decryptableuri.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareArgs;
|
||||
import org.thoughtcrime.securesms.stickers.StickerManifest;
|
||||
import org.thoughtcrime.securesms.stickers.StickerManifest.Sticker;
|
||||
import org.thoughtcrime.securesms.stickers.StickerRemoteUri;
|
||||
import org.thoughtcrime.securesms.stickers.StickerRolloverTouchListener;
|
||||
import org.thoughtcrime.securesms.stickers.StickerUrl;
|
||||
import org.thoughtcrime.securesms.stickers.manage.StickerManagementRepository;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter;
|
||||
import org.whispersystems.signalservice.api.util.OptionalUtil;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
|
||||
import kotlin.Pair;
|
||||
|
||||
/**
|
||||
* Shows the contents of a pack and allows the user to install it (if not installed) or remove it
|
||||
* (if installed). This is also the handler for sticker pack deep links.
|
||||
*/
|
||||
public final class StickerPackPreviewActivity extends PassphraseRequiredActivity
|
||||
implements StickerRolloverTouchListener.RolloverEventListener,
|
||||
StickerRolloverTouchListener.RolloverStickerRetriever,
|
||||
StickerPackPreviewAdapter.EventListener
|
||||
{
|
||||
|
||||
private static final String TAG = Log.tag(StickerPackPreviewActivity.class);
|
||||
|
||||
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
|
||||
|
||||
private StickerPackPreviewViewModel viewModel;
|
||||
|
||||
private ImageView coverImage;
|
||||
private TextView stickerTitle;
|
||||
private TextView stickerAuthor;
|
||||
private View installButton;
|
||||
private View removeButton;
|
||||
private RecyclerView stickerList;
|
||||
private View shareButton;
|
||||
private View shareButtonImage;
|
||||
|
||||
private StickerPackPreviewAdapter adapter;
|
||||
private GridLayoutManager layoutManager;
|
||||
private StickerRolloverTouchListener touchListener;
|
||||
|
||||
public static Intent getIntent(@NonNull String packId, @NonNull String packKey) {
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW, StickerUrl.createActionUri(packId, packKey));
|
||||
intent.addCategory(Intent.CATEGORY_DEFAULT);
|
||||
intent.addCategory(Intent.CATEGORY_BROWSABLE);
|
||||
return intent;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPreCreate() {
|
||||
super.onPreCreate();
|
||||
dynamicTheme.onCreate(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState, boolean ready) {
|
||||
setContentView(R.layout.sticker_preview_activity);
|
||||
|
||||
Optional<Pair<String, String>> stickerParams = StickerUrl.parseExternalUri(getIntent().getData());
|
||||
|
||||
if (!stickerParams.isPresent()) {
|
||||
Log.w(TAG, "Invalid URI!");
|
||||
presentError();
|
||||
return;
|
||||
}
|
||||
|
||||
String packId = stickerParams.get().getFirst();
|
||||
String packKey = stickerParams.get().getSecond();
|
||||
|
||||
initToolbar();
|
||||
initView();
|
||||
initViewModel(packId, packKey);
|
||||
|
||||
getSupportFragmentManager().setFragmentResultListener(MultiselectForwardFragment.RESULT_KEY, this, (requestKey, result) -> {
|
||||
if (result.getBoolean(MultiselectForwardFragment.RESULT_SENT, false)) {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
dynamicTheme.onResume(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(Configuration newConfig) {
|
||||
super.onConfigurationChanged(newConfig);
|
||||
onScreenWidthChanged(getScreenWidth());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStickerLongPress(@NonNull View view) {
|
||||
if (touchListener != null) {
|
||||
touchListener.enterHoverMode(stickerList, view);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStickerPopupStarted() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStickerPopupEnded() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Pair<Object, String> getStickerDataFromView(@NonNull View view) {
|
||||
if (stickerList != null) {
|
||||
StickerPackPreviewAdapter.StickerViewHolder holder = (StickerPackPreviewAdapter.StickerViewHolder) stickerList.getChildViewHolder(view);
|
||||
if (holder != null) {
|
||||
return new Pair<>(holder.getCurrentGlideModel(), holder.getCurrentEmoji());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void initView() {
|
||||
this.coverImage = findViewById(R.id.sticker_install_cover);
|
||||
this.stickerTitle = findViewById(R.id.sticker_install_title);
|
||||
this.stickerAuthor = findViewById(R.id.sticker_install_author);
|
||||
this.installButton = findViewById(R.id.sticker_install_button);
|
||||
this.removeButton = findViewById(R.id.sticker_install_remove_button);
|
||||
this.stickerList = findViewById(R.id.sticker_install_list);
|
||||
this.shareButton = findViewById(R.id.sticker_install_share_button);
|
||||
this.shareButtonImage = findViewById(R.id.sticker_install_share_button_image);
|
||||
|
||||
SystemWindowInsetsSetter.attach(installButton, this, WindowInsetsCompat.Type.navigationBars(), SystemWindowInsetsSetter.ApplyMode.MARGIN);
|
||||
SystemWindowInsetsSetter.attach(removeButton, this, WindowInsetsCompat.Type.navigationBars(), SystemWindowInsetsSetter.ApplyMode.MARGIN);
|
||||
|
||||
this.adapter = new StickerPackPreviewAdapter(Glide.with(this), this, true);
|
||||
this.layoutManager = new GridLayoutManager(this, 2);
|
||||
this.touchListener = new StickerRolloverTouchListener(this, Glide.with(this), this, this);
|
||||
onScreenWidthChanged(getScreenWidth());
|
||||
|
||||
stickerList.setLayoutManager(layoutManager);
|
||||
stickerList.addOnItemTouchListener(touchListener);
|
||||
stickerList.setAdapter(adapter);
|
||||
}
|
||||
|
||||
private void initToolbar() {
|
||||
Toolbar toolbar = findViewById(R.id.sticker_install_toolbar);
|
||||
|
||||
SystemWindowInsetsSetter.attach(toolbar, this, WindowInsetsCompat.Type.statusBars());
|
||||
|
||||
setSupportActionBar(toolbar);
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
getSupportActionBar().setTitle(R.string.StickerPackPreviewActivity_stickers);
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> onBackPressed());
|
||||
}
|
||||
|
||||
private void initViewModel(@NonNull String packId, @NonNull String packKey) {
|
||||
viewModel = new ViewModelProvider(this, new StickerPackPreviewViewModel.Factory(getApplication(),
|
||||
new StickerPackPreviewRepository(),
|
||||
StickerManagementRepository.INSTANCE)
|
||||
).get(StickerPackPreviewViewModel.class);
|
||||
|
||||
viewModel.getStickerManifest(packId, packKey).observe(this, manifest -> {
|
||||
if (manifest == null) return;
|
||||
|
||||
if (manifest.isPresent()) {
|
||||
presentManifest(manifest.get().getManifest());
|
||||
presentButton(manifest.get().isInstalled());
|
||||
presentShareButton(manifest.get().isInstalled(), manifest.get().getManifest().getPackId(), manifest.get().getManifest().getPackKey());
|
||||
} else {
|
||||
presentError();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void presentManifest(@NonNull StickerManifest manifest) {
|
||||
stickerTitle.setText(manifest.getTitle().orElse(getString(R.string.StickerPackPreviewActivity_untitled)));
|
||||
stickerAuthor.setText(manifest.getAuthor().orElse(getString(R.string.StickerPackPreviewActivity_unknown)));
|
||||
adapter.setStickers(manifest.getStickers());
|
||||
|
||||
Sticker first = manifest.getStickers().isEmpty() ? null : manifest.getStickers().get(0);
|
||||
Sticker cover = OptionalUtil.or(manifest.getCover(), Optional.ofNullable(first)).orElse(null);
|
||||
|
||||
if (cover != null) {
|
||||
Object model = cover.getUri().isPresent() ? new DecryptableUri(cover.getUri().get())
|
||||
: new StickerRemoteUri(cover.getPackId(), cover.getPackKey(), cover.getId());
|
||||
Glide.with(this).load(model)
|
||||
.transition(DrawableTransitionOptions.withCrossFade())
|
||||
.fitCenter()
|
||||
.set(ApngOptions.ANIMATE, true)
|
||||
.into(coverImage);
|
||||
} else {
|
||||
coverImage.setImageDrawable(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void presentButton(boolean installed) {
|
||||
if (installed) {
|
||||
removeButton.setVisibility(View.VISIBLE);
|
||||
removeButton.setOnClickListener(v -> {
|
||||
viewModel.onRemoveClicked();
|
||||
finish();
|
||||
});
|
||||
installButton.setVisibility(View.GONE);
|
||||
installButton.setOnClickListener(null);
|
||||
} else {
|
||||
installButton.setVisibility(View.VISIBLE);
|
||||
installButton.setOnClickListener(v -> {
|
||||
viewModel.onInstallClicked();
|
||||
finish();
|
||||
});
|
||||
removeButton.setVisibility(View.GONE);
|
||||
removeButton.setOnClickListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void presentShareButton(boolean installed, @NonNull String packId, @NonNull String packKey) {
|
||||
if (installed) {
|
||||
shareButton.setVisibility(View.VISIBLE);
|
||||
shareButtonImage.setVisibility(View.VISIBLE);
|
||||
shareButton.setOnClickListener(v -> {
|
||||
MultiselectForwardFragment.showBottomSheet(
|
||||
getSupportFragmentManager(),
|
||||
new MultiselectForwardFragmentArgs(
|
||||
Collections.singletonList(new MultiShareArgs.Builder()
|
||||
.withDraftText(StickerUrl.createShareLink(packId, packKey))
|
||||
.build()),
|
||||
R.string.MultiselectForwardFragment__share_with
|
||||
)
|
||||
);
|
||||
});
|
||||
} else {
|
||||
shareButton.setVisibility(View.GONE);
|
||||
shareButtonImage.setVisibility(View.GONE);
|
||||
shareButton.setOnClickListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void presentError() {
|
||||
Toast.makeText(this, R.string.StickerPackPreviewActivity_failed_to_load_sticker_pack, Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
}
|
||||
|
||||
private void onScreenWidthChanged(int screenWidth) {
|
||||
if (layoutManager != null) {
|
||||
int availableWidth = screenWidth - (2 * getResources().getDimensionPixelOffset(R.dimen.sticker_preview_gutter_size));
|
||||
layoutManager.setSpanCount(availableWidth / getResources().getDimensionPixelOffset(R.dimen.sticker_preview_sticker_size));
|
||||
}
|
||||
}
|
||||
|
||||
private int getScreenWidth() {
|
||||
Point size = new Point();
|
||||
getWindowManager().getDefaultDisplay().getSize(size);
|
||||
return size.x;
|
||||
}
|
||||
}
|
||||
+480
-162
@@ -9,49 +9,68 @@ import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumptech.glide.load.Key
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.CollectActions
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.DropdownMenus
|
||||
import org.signal.core.ui.compose.IconButtons.IconButton
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.compose.dismissWithAnimation
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.core.util.toOptional
|
||||
@@ -65,13 +84,12 @@ import org.thoughtcrime.securesms.database.model.StickerPackId
|
||||
import org.thoughtcrime.securesms.database.model.StickerPackKey
|
||||
import org.thoughtcrime.securesms.database.model.StickerPackParams
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareArgs
|
||||
import org.thoughtcrime.securesms.stickers.BlessedPacks
|
||||
import org.thoughtcrime.securesms.stickers.StickerManifest
|
||||
import org.thoughtcrime.securesms.stickers.StickerPreviewDataFactory
|
||||
import org.thoughtcrime.securesms.stickers.StickerRemoteUri
|
||||
import org.thoughtcrime.securesms.stickers.StickerUrl
|
||||
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewUiState.ContentState
|
||||
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewUiState.NavTarget
|
||||
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewUiState.UserPrompt
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
import java.text.NumberFormat
|
||||
import kotlin.jvm.optionals.getOrElse
|
||||
@@ -105,97 +123,163 @@ class StickerPackPreviewActivityV2 : PassphraseRequiredActivity() {
|
||||
|
||||
setContent {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
uiState.navTarget?.let(::navigateToTarget)
|
||||
uiState.userMessage?.let(::showUserMessage)
|
||||
|
||||
CollectActions(viewModel.actions, ::handleAction)
|
||||
|
||||
SignalTheme {
|
||||
StickerPackPreviewScreen(
|
||||
uiState = uiState,
|
||||
callbacks = remember {
|
||||
object : StickerPackPreviewScreenCallbacks {
|
||||
override fun onNavigateTo(target: NavTarget) = navigateToTarget(target)
|
||||
override fun onForwardClick(params: StickerPackParams) = openShareSheet(params)
|
||||
override fun onInstallClick(params: StickerPackParams) = viewModel.installStickerPack(params)
|
||||
override fun onUninstallClick(params: StickerPackParams) = viewModel.uninstallStickerPack(params)
|
||||
}
|
||||
}
|
||||
onEvent = viewModel::onEvent,
|
||||
onNavigationClick = { onBackPressedDispatcher.onBackPressed() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToTarget(target: NavTarget) {
|
||||
lifecycleScope.launch {
|
||||
when (target) {
|
||||
is NavTarget.Up -> {
|
||||
lifecycleScope.launch {
|
||||
if (target.delay != null) delay(target.delay.inWholeMilliseconds)
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
}
|
||||
private fun handleAction(action: StickerPackPreviewAction) {
|
||||
when (action) {
|
||||
is StickerPackPreviewAction.SendPack -> openPackShareSheet(action.params)
|
||||
is StickerPackPreviewAction.ShareExternally -> openSystemShareSheet(action.params)
|
||||
StickerPackPreviewAction.LinkCopied -> {
|
||||
Toast.makeText(this, R.string.StickerManagement_copied, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
viewModel.setNavTargetConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUserMessage(type: StickerPackPreviewUiState.MessageType) {
|
||||
when (type) {
|
||||
StickerPackPreviewUiState.MessageType.STICKER_PACK_LOAD_FAILED -> {
|
||||
StickerPackPreviewAction.PackUnavailable -> {
|
||||
Toast.makeText(this, R.string.StickerPackPreviewActivity_failed_to_load_sticker_pack, Toast.LENGTH_SHORT).show()
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
}
|
||||
viewModel.setUserMessageConsumed()
|
||||
}
|
||||
|
||||
private fun openShareSheet(params: StickerPackParams) {
|
||||
supportFragmentManager.setFragmentResultListener(MultiselectForwardFragment.RESULT_KEY, this) { _: String?, result: Bundle ->
|
||||
if (result.getBoolean(MultiselectForwardFragment.RESULT_SENT, false)) {
|
||||
navigateToTarget(NavTarget.Up())
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPackShareSheet(params: StickerPackParams) {
|
||||
MultiselectForwardFragment.showBottomSheet(
|
||||
supportFragmentManager = supportFragmentManager,
|
||||
multiselectForwardFragmentArgs = MultiselectForwardFragmentArgs(
|
||||
multiShareArgs = listOf(
|
||||
MultiShareArgs.Builder()
|
||||
.withDraftText(StickerUrl.createShareLink(params.id.value, params.key.value))
|
||||
.withDraftText(params.shareLink)
|
||||
.build()
|
||||
),
|
||||
title = R.string.StickerManagement_share_sheet_title
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface StickerPackPreviewScreenCallbacks {
|
||||
fun onNavigateTo(target: NavTarget)
|
||||
fun onForwardClick(params: StickerPackParams)
|
||||
fun onInstallClick(params: StickerPackParams)
|
||||
fun onUninstallClick(params: StickerPackParams)
|
||||
private fun openSystemShareSheet(params: StickerPackParams) {
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, params.shareLink)
|
||||
}
|
||||
|
||||
object Empty : StickerPackPreviewScreenCallbacks {
|
||||
override fun onNavigateTo(target: NavTarget) = Unit
|
||||
override fun onForwardClick(params: StickerPackParams) = Unit
|
||||
override fun onInstallClick(params: StickerPackParams) = Unit
|
||||
override fun onUninstallClick(params: StickerPackParams) = Unit
|
||||
startActivity(Intent.createChooser(shareIntent, null))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StickerPackPreviewScreen(
|
||||
uiState: StickerPackPreviewUiState,
|
||||
callbacks: StickerPackPreviewScreenCallbacks
|
||||
onEvent: (StickerPackPreviewEvent) -> Unit,
|
||||
onNavigationClick: () -> Unit
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
stickerManifest = if (uiState.contentState is ContentState.HasData) uiState.contentState.stickerManifest else null,
|
||||
onNavigateUp = { callbacks.onNavigateTo(NavTarget.Up()) },
|
||||
onForwardClick = callbacks::onForwardClick
|
||||
val gridState = rememberLazyGridState()
|
||||
val showToolbarDetails by remember { derivedStateOf { gridState.firstVisibleItemIndex > 0 } }
|
||||
val toolbarAlpha by animateFloatAsState(targetValue = if (showToolbarDetails) 1f else 0f, label = "toolbar-alpha")
|
||||
|
||||
val loadedState = uiState.contentState as? ContentState.HasData
|
||||
val stickerManifest = loadedState?.stickerManifest
|
||||
val untitledPackTitle = stringResource(R.string.StickerPackPreviewActivity_untitled)
|
||||
val menuController = remember { DropdownMenus.MenuController() }
|
||||
|
||||
LaunchedEffect(showToolbarDetails) {
|
||||
if (!showToolbarDetails) {
|
||||
menuController.hide()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = stickerManifest?.let { it.title.orNull() ?: untitledPackTitle } ?: "",
|
||||
onNavigationClick = onNavigationClick,
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector,
|
||||
navigationContentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description),
|
||||
titleContent = { _, toolbarTitle ->
|
||||
ToolbarTitle(
|
||||
title = toolbarTitle,
|
||||
alpha = toolbarAlpha,
|
||||
isBlessed = loadedState?.isBlessed ?: false
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
if (loadedState != null && toolbarAlpha > 0f) {
|
||||
IconButton(
|
||||
onClick = { menuController.show() },
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.alpha(toolbarAlpha)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_more_vertical),
|
||||
contentDescription = stringResource(R.string.StickerManagement_accessibility_open_top_bar_menu)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenus.Menu(
|
||||
controller = menuController,
|
||||
offsetX = 24.dp,
|
||||
offsetY = 0.dp
|
||||
) {
|
||||
DropdownMenus.ItemWithIcon(
|
||||
menuController = menuController,
|
||||
imageVector = SignalIcons.Forward.imageVector,
|
||||
stringResId = R.string.StickerManagement_menu_send_pack,
|
||||
onClick = { onEvent(StickerPackPreviewEvent.SendPackClicked) }
|
||||
)
|
||||
|
||||
DropdownMenus.ItemWithIcon(
|
||||
menuController = menuController,
|
||||
imageVector = SignalIcons.Link.imageVector,
|
||||
stringResId = R.string.StickerManagement_menu_link_pack,
|
||||
onClick = { onEvent(StickerPackPreviewEvent.LinkPackClicked) }
|
||||
)
|
||||
|
||||
if (loadedState.isPackInstalled) {
|
||||
DropdownMenus.ItemWithIcon(
|
||||
menuController = menuController,
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_minus_circle_24),
|
||||
stringResId = R.string.StickerManagement_menu_remove_pack,
|
||||
onClick = { onEvent(StickerPackPreviewEvent.UninstallClicked) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
if (loadedState != null && !loadedState.isPackInstalled) {
|
||||
InstallPackButton(
|
||||
onClick = { onEvent(StickerPackPreviewEvent.InstallClicked) }
|
||||
)
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
if (uiState.userPrompt is UserPrompt.ConfirmRemovePack) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = pluralStringResource(R.plurals.StickerManagement_delete_n_packs_confirmation, 1, 1),
|
||||
body = pluralStringResource(R.plurals.StickerManagement_delete_n_packs_confirmation_body, 1, 1),
|
||||
confirm = stringResource(R.string.StickerManagement_menu_remove_pack),
|
||||
confirmColor = MaterialTheme.colorScheme.error,
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = { onEvent(StickerPackPreviewEvent.UninstallConfirmed) },
|
||||
onDeny = { onEvent(StickerPackPreviewEvent.UninstallCanceled) },
|
||||
onDismissRequest = { onEvent(StickerPackPreviewEvent.UninstallCanceled) }
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.userPrompt is UserPrompt.ShareStickerPack && stickerManifest != null) {
|
||||
StickerPackShareSheet(
|
||||
params = stickerManifest.params,
|
||||
stickerManifest = stickerManifest,
|
||||
onEvent = onEvent
|
||||
)
|
||||
}
|
||||
|
||||
when (uiState.contentState) {
|
||||
is ContentState.Loading -> {
|
||||
Box(
|
||||
@@ -209,12 +293,10 @@ private fun StickerPackPreviewScreen(
|
||||
}
|
||||
|
||||
is ContentState.HasData -> {
|
||||
uiState.navTarget?.let(callbacks::onNavigateTo)
|
||||
|
||||
StickerPackPreviewContent(
|
||||
contentState = uiState.contentState,
|
||||
onInstallClick = callbacks::onInstallClick,
|
||||
onUninstallClick = callbacks::onUninstallClick,
|
||||
gridState = gridState,
|
||||
onEvent = onEvent,
|
||||
modifier = Modifier.padding(padding)
|
||||
)
|
||||
}
|
||||
@@ -224,93 +306,116 @@ private fun StickerPackPreviewScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopAppBar(
|
||||
stickerManifest: StickerManifest?,
|
||||
onNavigateUp: () -> Unit,
|
||||
onForwardClick: (params: StickerPackParams) -> Unit
|
||||
private fun ToolbarTitle(
|
||||
title: String,
|
||||
alpha: Float,
|
||||
isBlessed: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Scaffolds.DefaultTopAppBar(
|
||||
title = "", // TODO collapse title into top app bar on scroll
|
||||
titleContent = { _, text -> Text(text = text, style = MaterialTheme.typography.titleLarge) },
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector,
|
||||
navigationContentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description),
|
||||
onNavigationClick = onNavigateUp,
|
||||
actions = {
|
||||
if (stickerManifest != null) {
|
||||
IconButton(
|
||||
onClick = { onForwardClick(stickerManifest.params) },
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = SignalIcons.Forward.imageVector,
|
||||
contentDescription = stringResource(R.string.StickerManagement_menu_forward_pack)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StickerPackPreviewContent(
|
||||
modifier: Modifier = Modifier,
|
||||
contentState: ContentState.HasData,
|
||||
onInstallClick: (params: StickerPackParams) -> Unit,
|
||||
onUninstallClick: (params: StickerPackParams) -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.padding(top = 0.dp, bottom = 12.dp, start = 16.dp, end = 16.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.alpha(alpha)
|
||||
) {
|
||||
StickerPackInfo(
|
||||
stickerManifest = contentState.stickerManifest,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 96.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
modifier = Modifier
|
||||
.padding(top = 32.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
items(
|
||||
items = contentState.stickerManifest.stickers,
|
||||
key = { it.id }
|
||||
) { item ->
|
||||
StickerImage(
|
||||
sticker = item,
|
||||
modifier = Modifier.size(96.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val actionButtonModifier = Modifier
|
||||
.width(412.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(top = 12.dp)
|
||||
|
||||
if (contentState.isPackInstalled) {
|
||||
Buttons.LargeTonal(
|
||||
content = { Text(text = stringResource(R.string.StickerManagement_menu_remove_pack)) },
|
||||
onClick = { onUninstallClick(contentState.stickerManifest.params) },
|
||||
colors = ButtonDefaults.filledTonalButtonColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
modifier = actionButtonModifier
|
||||
)
|
||||
} else {
|
||||
Buttons.LargeTonal(
|
||||
content = { Text(text = stringResource(R.string.StickerManagement_menu_install_pack)) },
|
||||
onClick = { onInstallClick(contentState.stickerManifest.params) },
|
||||
modifier = actionButtonModifier
|
||||
if (isBlessed) {
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_official_20),
|
||||
contentDescription = stringResource(R.string.ConversationTitleView_verified),
|
||||
modifier = Modifier
|
||||
.padding(start = 4.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StickerPackInfo(
|
||||
private fun StickerPackPreviewContent(
|
||||
modifier: Modifier = Modifier,
|
||||
contentState: ContentState.HasData,
|
||||
gridState: LazyGridState,
|
||||
onEvent: (StickerPackPreviewEvent) -> Unit
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
state = gridState,
|
||||
columns = GridCells.Adaptive(minSize = 96.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
contentPadding = PaddingValues(bottom = 12.dp, start = 16.dp, end = 16.dp),
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
StickerPackTitle(
|
||||
isBlessed = contentState.isBlessed,
|
||||
stickerManifest = contentState.stickerManifest,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
StickerPackInfo(
|
||||
stickerManifest = contentState.stickerManifest,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
StickerOptions(
|
||||
contentState = contentState,
|
||||
onEvent = onEvent,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
items = contentState.stickerManifest.stickers,
|
||||
key = { it.id }
|
||||
) { item ->
|
||||
StickerImage(
|
||||
sticker = item,
|
||||
modifier = Modifier.size(96.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstallPackButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = modifier
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
content = { Text(text = stringResource(R.string.StickerManagement__add_stickers)) },
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 412.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StickerPackTitle(
|
||||
isBlessed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
stickerManifest: StickerManifest
|
||||
) {
|
||||
@@ -327,34 +432,43 @@ private fun StickerPackInfo(
|
||||
)
|
||||
}
|
||||
|
||||
stickerManifest.title.orNull()?.let { title ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stickerManifest.title.orNull() ?: stringResource(R.string.StickerPackPreviewActivity_untitled),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
if (BlessedPacks.contains(stickerManifest.packId)) {
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_official_20),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 6.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
if (isBlessed) {
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_official_20),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StickerPackInfo(
|
||||
modifier: Modifier = Modifier,
|
||||
stickerManifest: StickerManifest
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = stickerManifest.author
|
||||
.filter { it.isNotBlank() }
|
||||
.getOrElse { stringResource(R.string.StickerManagement_author_unknown) },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp)
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Text(
|
||||
@@ -366,6 +480,149 @@ private fun StickerPackInfo(
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StickerOptions(
|
||||
contentState: ContentState.HasData,
|
||||
onEvent: (StickerPackPreviewEvent) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
Buttons.ActionButton(
|
||||
onClick = { onEvent(StickerPackPreviewEvent.SendPackClicked) },
|
||||
imageVector = SignalIcons.Forward.imageVector,
|
||||
label = stringResource(R.string.StickerManagement_menu_send_pack)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Buttons.ActionButton(
|
||||
onClick = { onEvent(StickerPackPreviewEvent.LinkPackClicked) },
|
||||
imageVector = SignalIcons.Link.imageVector,
|
||||
label = stringResource(R.string.StickerManagement_menu_link_pack)
|
||||
)
|
||||
|
||||
if (contentState.isPackInstalled) {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Buttons.ActionButton(
|
||||
onClick = { onEvent(StickerPackPreviewEvent.UninstallClicked) },
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_minus_circle_24),
|
||||
label = stringResource(R.string.StickerManagement_menu_remove_pack)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun StickerPackShareSheet(
|
||||
params: StickerPackParams,
|
||||
stickerManifest: StickerManifest,
|
||||
onEvent: (StickerPackPreviewEvent) -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
BottomSheets.BottomSheet(
|
||||
sheetState = sheetState,
|
||||
onDismissRequest = { onEvent(StickerPackPreviewEvent.ShareSheetDismissed) }
|
||||
) {
|
||||
StickerPackShareSheetContent(
|
||||
params = params,
|
||||
stickerManifest = stickerManifest,
|
||||
onForwardClick = { sheetState.dismissWithAnimation(scope, onComplete = { onEvent(StickerPackPreviewEvent.SendPackClicked) }) },
|
||||
onCopyClick = { sheetState.dismissWithAnimation(scope, onComplete = { onEvent(StickerPackPreviewEvent.CopyLinkClicked) }) },
|
||||
onShareExternallyClick = { sheetState.dismissWithAnimation(scope, onComplete = { onEvent(StickerPackPreviewEvent.ShareExternallyClicked) }) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StickerPackShareSheetContent(
|
||||
params: StickerPackParams,
|
||||
stickerManifest: StickerManifest,
|
||||
onForwardClick: () -> Unit,
|
||||
onCopyClick: () -> Unit,
|
||||
onShareExternallyClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.StickerManagement__anyone),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 16.dp, horizontal = 32.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(start = 24.dp, end = 24.dp, bottom = 16.dp)
|
||||
.border(1.dp, color = MaterialTheme.colorScheme.outline.copy(alpha = 0.38f), shape = RoundedCornerShape(12.dp))
|
||||
.padding(16.dp)
|
||||
) {
|
||||
stickerManifest.cover.orNull()?.let { cover ->
|
||||
StickerImage(
|
||||
sticker = cover,
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp)
|
||||
) {
|
||||
stickerManifest.title.orNull()?.let { title ->
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
stickerManifest.author.orNull()?.let { author ->
|
||||
Text(
|
||||
text = author,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = params.shareLink,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.StickerManagement_menu_send_signal_pack),
|
||||
icon = SignalIcons.Forward.imageVector,
|
||||
onClick = onForwardClick
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.StickerManagement_menu_copy_pack),
|
||||
icon = SignalIcons.Copy.imageVector,
|
||||
onClick = onCopyClick
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.StickerManagement_menu_share_pack),
|
||||
icon = SignalIcons.Share.imageVector,
|
||||
onClick = onShareExternallyClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,10 +669,68 @@ private fun HasDataPreview() {
|
||||
cover.toOptional(),
|
||||
StickerPreviewDataFactory.manifestStickers(33)
|
||||
),
|
||||
isPackInstalled = false
|
||||
isPackInstalled = false,
|
||||
isBlessed = true
|
||||
)
|
||||
),
|
||||
callbacks = StickerPackPreviewScreenCallbacks.Empty
|
||||
onEvent = {},
|
||||
onNavigationClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun HasDataPreviewInstalled() {
|
||||
Previews.Preview {
|
||||
val cover = StickerManifest.Sticker(
|
||||
"packId0",
|
||||
"packKey0",
|
||||
0,
|
||||
"👍",
|
||||
null
|
||||
)
|
||||
|
||||
StickerPackPreviewScreen(
|
||||
uiState = StickerPackPreviewUiState(
|
||||
contentState = ContentState.HasData(
|
||||
stickerManifest = StickerManifest(
|
||||
cover.packId,
|
||||
cover.packKey,
|
||||
"Misbrands (The world's most hated IT stickers extended)".toOptional(),
|
||||
"Sticker Pack Author".toOptional(),
|
||||
cover.toOptional(),
|
||||
StickerPreviewDataFactory.manifestStickers(33)
|
||||
),
|
||||
isPackInstalled = true,
|
||||
isBlessed = true
|
||||
)
|
||||
),
|
||||
onEvent = {},
|
||||
onNavigationClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun StickerPackShareSheetContentPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
val cover = StickerManifest.Sticker("packId0", "packKey0", 0, "👍", null)
|
||||
|
||||
StickerPackShareSheetContent(
|
||||
params = StickerPackParams(StickerPackId(cover.packId), StickerPackKey(cover.packKey)),
|
||||
stickerManifest = StickerManifest(
|
||||
cover.packId,
|
||||
cover.packKey,
|
||||
"Misbrands".toOptional(),
|
||||
"Sticker Pack Author".toOptional(),
|
||||
cover.toOptional(),
|
||||
StickerPreviewDataFactory.manifestStickers(33)
|
||||
),
|
||||
onForwardClick = {},
|
||||
onCopyClick = {},
|
||||
onShareExternallyClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -430,3 +745,6 @@ private val StickerManifest.params: StickerPackParams
|
||||
id = StickerPackId(packId),
|
||||
key = StickerPackKey(packKey)
|
||||
)
|
||||
|
||||
private val StickerPackParams.shareLink: String
|
||||
get() = StickerUrl.createShareLink(id.value, key.value)
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.stickers.preview
|
||||
|
||||
/**
|
||||
* Events that happen in [StickerPackPreviewActivityV2]
|
||||
*/
|
||||
sealed interface StickerPackPreviewEvent {
|
||||
data object Initialize : StickerPackPreviewEvent
|
||||
data object InstallClicked : StickerPackPreviewEvent
|
||||
data object UninstallClicked : StickerPackPreviewEvent
|
||||
data object UninstallConfirmed : StickerPackPreviewEvent
|
||||
data object UninstallCanceled : StickerPackPreviewEvent
|
||||
data object SendPackClicked : StickerPackPreviewEvent
|
||||
data object LinkPackClicked : StickerPackPreviewEvent
|
||||
data object ShareSheetDismissed : StickerPackPreviewEvent
|
||||
data object CopyLinkClicked : StickerPackPreviewEvent
|
||||
data object ShareExternallyClicked : StickerPackPreviewEvent
|
||||
}
|
||||
+112
-55
@@ -5,68 +5,136 @@
|
||||
|
||||
package org.thoughtcrime.securesms.stickers.preview
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.signal.core.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.util.Util
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.database.model.StickerPackParams
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.stickers.BlessedPacks
|
||||
import org.thoughtcrime.securesms.stickers.StickerManifest
|
||||
import org.thoughtcrime.securesms.stickers.StickerUrl
|
||||
import org.thoughtcrime.securesms.stickers.manage.StickerManagementRepository
|
||||
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewUiState.ContentState
|
||||
import kotlin.jvm.optionals.getOrElse
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import org.thoughtcrime.securesms.stickers.preview.StickerPackPreviewUiState.UserPrompt
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class StickerPackPreviewViewModelV2(
|
||||
params: StickerPackParams?
|
||||
) : ViewModel() {
|
||||
private val params: StickerPackParams?
|
||||
) : EventDrivenViewModel<StickerPackPreviewEvent>(TAG) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(StickerPackPreviewViewModelV2::class)
|
||||
}
|
||||
|
||||
private val stickerPreviewRepo: StickerPackPreviewRepository = StickerPackPreviewRepository()
|
||||
private val internalUiState = MutableStateFlow(StickerPackPreviewUiState(contentState = ContentState.Loading))
|
||||
val uiState: StateFlow<StickerPackPreviewUiState> = internalUiState.asStateFlow()
|
||||
|
||||
private val internalActions: Channel<StickerPackPreviewAction> = Channel(Channel.BUFFERED)
|
||||
val actions: Flow<StickerPackPreviewAction> = internalActions.receiveAsFlow()
|
||||
|
||||
init {
|
||||
if (params != null) {
|
||||
loadData(params)
|
||||
} else {
|
||||
showDataUnavailableState()
|
||||
}
|
||||
onEvent(StickerPackPreviewEvent.Initialize)
|
||||
}
|
||||
|
||||
private fun loadData(params: StickerPackParams) {
|
||||
stickerPreviewRepo.getStickerManifest(params.id.value, params.key.value) { result ->
|
||||
val stickerManifest = result.map { it.manifest }.orNull()
|
||||
if (stickerManifest != null) {
|
||||
internalUiState.update {
|
||||
it.copy(
|
||||
contentState = ContentState.HasData(
|
||||
stickerManifest = stickerManifest,
|
||||
isPackInstalled = result.map { pack -> pack.isInstalled }.getOrElse { false }
|
||||
)
|
||||
)
|
||||
override suspend fun processEvent(event: StickerPackPreviewEvent) {
|
||||
if (params == null) {
|
||||
Log.w(TAG, "No pack params to work with.")
|
||||
showDataUnavailable()
|
||||
return
|
||||
}
|
||||
|
||||
when (event) {
|
||||
StickerPackPreviewEvent.Initialize -> {
|
||||
viewModelScope.launch { loadManifest(params) }
|
||||
}
|
||||
|
||||
StickerPackPreviewEvent.InstallClicked -> {
|
||||
if (!isPackInstalled()) {
|
||||
updateInstalledState(true)
|
||||
StickerManagementRepository.installStickerPack(packId = params.id, packKey = params.key, notify = true)
|
||||
}
|
||||
} else {
|
||||
showDataUnavailableState()
|
||||
}
|
||||
|
||||
StickerPackPreviewEvent.UninstallClicked -> showPrompt(UserPrompt.ConfirmRemovePack)
|
||||
|
||||
StickerPackPreviewEvent.UninstallConfirmed -> {
|
||||
showPrompt(null)
|
||||
|
||||
if (isPackInstalled()) {
|
||||
updateInstalledState(false)
|
||||
StickerManagementRepository.uninstallStickerPacks(mapOf(params.id to params.key))
|
||||
}
|
||||
}
|
||||
|
||||
StickerPackPreviewEvent.UninstallCanceled -> showPrompt(null)
|
||||
|
||||
StickerPackPreviewEvent.SendPackClicked -> {
|
||||
showPrompt(null)
|
||||
internalActions.send(StickerPackPreviewAction.SendPack(params))
|
||||
}
|
||||
|
||||
StickerPackPreviewEvent.LinkPackClicked -> showPrompt(UserPrompt.ShareStickerPack)
|
||||
|
||||
StickerPackPreviewEvent.ShareSheetDismissed -> showPrompt(null)
|
||||
|
||||
StickerPackPreviewEvent.CopyLinkClicked -> {
|
||||
showPrompt(null)
|
||||
Util.copyToClipboard(AppDependencies.application, StickerUrl.createShareLink(params.id.value, params.key.value))
|
||||
internalActions.send(StickerPackPreviewAction.LinkCopied)
|
||||
}
|
||||
|
||||
StickerPackPreviewEvent.ShareExternallyClicked -> {
|
||||
showPrompt(null)
|
||||
internalActions.send(StickerPackPreviewAction.ShareExternally(params))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun installStickerPack(params: StickerPackParams) {
|
||||
viewModelScope.launch {
|
||||
StickerManagementRepository.installStickerPack(packId = params.id, packKey = params.key, notify = true)
|
||||
updateInstalledState(true)
|
||||
private suspend fun loadManifest(params: StickerPackParams) {
|
||||
val loaded = fetchManifest(params)
|
||||
|
||||
if (loaded == null) {
|
||||
showDataUnavailable()
|
||||
return
|
||||
}
|
||||
|
||||
internalUiState.update { it.copy(contentState = loaded) }
|
||||
}
|
||||
|
||||
private suspend fun fetchManifest(params: StickerPackParams): ContentState.HasData? {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
stickerPreviewRepo.getStickerManifest(params.id.value, params.key.value) { result ->
|
||||
continuation.resume(
|
||||
result.map { pack ->
|
||||
ContentState.HasData(
|
||||
stickerManifest = pack.manifest,
|
||||
isPackInstalled = pack.isInstalled,
|
||||
isBlessed = BlessedPacks.contains(params.id.value)
|
||||
)
|
||||
}.orNull()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun uninstallStickerPack(params: StickerPackParams) {
|
||||
viewModelScope.launch {
|
||||
StickerManagementRepository.uninstallStickerPacks(mapOf(params.id to params.key))
|
||||
updateInstalledState(false)
|
||||
}
|
||||
private fun showPrompt(prompt: UserPrompt?) {
|
||||
internalUiState.update { it.copy(userPrompt = prompt) }
|
||||
}
|
||||
|
||||
private fun isPackInstalled(): Boolean {
|
||||
return (internalUiState.value.contentState as? ContentState.HasData)?.isPackInstalled == true
|
||||
}
|
||||
|
||||
private fun updateInstalledState(isInstalled: Boolean) {
|
||||
@@ -76,35 +144,26 @@ class StickerPackPreviewViewModelV2(
|
||||
it.contentState.copy(isPackInstalled = isInstalled)
|
||||
} else {
|
||||
it.contentState
|
||||
},
|
||||
navTarget = StickerPackPreviewUiState.NavTarget.Up(delay = 500.milliseconds)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDataUnavailableState() {
|
||||
private suspend fun showDataUnavailable() {
|
||||
internalUiState.update {
|
||||
it.copy(
|
||||
contentState = ContentState.DataUnavailable,
|
||||
userMessage = StickerPackPreviewUiState.MessageType.STICKER_PACK_LOAD_FAILED,
|
||||
navTarget = StickerPackPreviewUiState.NavTarget.Up(delay = 1.seconds)
|
||||
userPrompt = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setNavTargetConsumed() {
|
||||
internalUiState.update { it.copy(navTarget = null) }
|
||||
}
|
||||
|
||||
fun setUserMessageConsumed() {
|
||||
internalUiState.update { it.copy(userMessage = null) }
|
||||
internalActions.send(StickerPackPreviewAction.PackUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
data class StickerPackPreviewUiState(
|
||||
val contentState: ContentState,
|
||||
val userMessage: MessageType? = null,
|
||||
val navTarget: NavTarget? = null
|
||||
val userPrompt: UserPrompt? = null
|
||||
) {
|
||||
sealed interface ContentState {
|
||||
data object Loading : ContentState
|
||||
@@ -112,15 +171,13 @@ data class StickerPackPreviewUiState(
|
||||
|
||||
data class HasData(
|
||||
val stickerManifest: StickerManifest,
|
||||
val isPackInstalled: Boolean
|
||||
val isPackInstalled: Boolean,
|
||||
val isBlessed: Boolean
|
||||
) : ContentState
|
||||
}
|
||||
|
||||
sealed interface NavTarget {
|
||||
data class Up(val delay: Duration? = null) : NavTarget
|
||||
}
|
||||
|
||||
enum class MessageType {
|
||||
STICKER_PACK_LOAD_FAILED
|
||||
sealed interface UserPrompt {
|
||||
data object ConfirmRemovePack : UserPrompt
|
||||
data object ShareStickerPack : UserPrompt
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2789,7 +2789,7 @@
|
||||
<string name="StickerManagement_action_delete_selected">Delete</string>
|
||||
<!-- Confirmation message shown before uninstalling sticker packs. The placeholder is the number of items to delete. -->
|
||||
<plurals name="StickerManagement_delete_n_packs_confirmation">
|
||||
<item quantity="one">Remove %1$s sticker pack?</item>
|
||||
<item quantity="one">Remove sticker pack?</item>
|
||||
<item quantity="other">Remove %1$s sticker packs?</item>
|
||||
</plurals>
|
||||
<!-- Body of the confirmation dialog shown before uninstalling sticker packs. -->
|
||||
@@ -2832,7 +2832,14 @@
|
||||
<string name="StickerManagement_search">Search</string>
|
||||
<!-- Shown when no stickers match the search. The placeholder what was searched. -->
|
||||
<string name="StickerManagement_search_no_results_s">No results found for \'%1$s\'</string>
|
||||
|
||||
<!-- Sticker pack button for forwarding a sticker link. -->
|
||||
<string name="StickerManagement_menu_send_signal_pack">Send via Signal</string>
|
||||
<!-- Sticker pack button that opens a bottom sheet with more share options. -->
|
||||
<string name="StickerManagement_menu_link_pack">Link</string>
|
||||
<!-- Sticker pack sheet option for sharing a sticker pack link using the system share sheet. -->
|
||||
<string name="StickerManagement_menu_share_pack">Share</string>
|
||||
<!-- Bottom sheet description for the share sticker sheet. -->
|
||||
<string name="StickerManagement__anyone">Anyone with this link can install and share this sticker pack.</string>
|
||||
<!-- Accessibility label for the button to download a sticker pack. The placeholder is the sticker pack name. -->
|
||||
<string name="StickerManagement_accessibility_download_pack">Download %s sticker pack</string>
|
||||
<!-- Accessibility label for the icon that is displayed after a sticker pack has been downloaded. The placeholder is the sticker pack name. -->
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.signal.core.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.mediakeyboard.data.MediaKeyboardRepository
|
||||
|
||||
/**
|
||||
* @param isEnteringText Whether the search surface is up, from the scaffold that owns it.
|
||||
* @param initialTab The tab to open on, or null to open on the first one offered.
|
||||
*/
|
||||
class MediaKeyboardViewModel(
|
||||
private val repository: MediaKeyboardRepository,
|
||||
isEnteringText: StateFlow<Boolean>,
|
||||
initialTab: MediaKeyboardTab? = null
|
||||
) : EventDrivenViewModel<MediaKeyboardScreenEvents>(TAG, shouldLogEvents = false) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(MediaKeyboardViewModel::class)
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(
|
||||
initialTab?.let { MediaKeyboardState(preferredTab = it) } ?: MediaKeyboardState()
|
||||
)
|
||||
val state: StateFlow<MediaKeyboardState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
onEvent(MediaKeyboardScreenEvents.Initialize)
|
||||
|
||||
isEnteringText
|
||||
.onEach { entering -> _state.update { it.copy(searchActive = entering) } }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override suspend fun processEvent(event: MediaKeyboardScreenEvents) {
|
||||
applyEvent(_state.value, event) { next ->
|
||||
_state.update { current -> next.copy(searchActive = current.searchActive, restrictedTo = current.restrictedTo) }
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
suspend fun applyEvent(
|
||||
state: MediaKeyboardState,
|
||||
event: MediaKeyboardScreenEvents,
|
||||
stateEmitter: (MediaKeyboardState) -> Unit
|
||||
) {
|
||||
when (event) {
|
||||
is MediaKeyboardScreenEvents.Initialize -> {
|
||||
val offered = repository.getAvailableTabs()
|
||||
stateEmitter(state.copy(offeredTabs = MediaKeyboardTab.entries.filter { it in offered }, initialized = true))
|
||||
}
|
||||
is MediaKeyboardScreenEvents.TabSelected -> {
|
||||
stateEmitter(state.copy(preferredTab = event.tab, searchQuery = ""))
|
||||
}
|
||||
is MediaKeyboardScreenEvents.SearchQueryChanged -> {
|
||||
stateEmitter(state.copy(searchQuery = event.query))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows the keyboard to [tabs], or null to offer everything again. Not an event: it is the
|
||||
* host's standing configuration rather than something the user did, and applying it needs nothing
|
||||
* the repository has to fetch.
|
||||
*/
|
||||
fun restrictTabs(tabs: Set<MediaKeyboardTab>?) {
|
||||
_state.update { it.copy(restrictedTo = tabs) }
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val repository: MediaKeyboardRepository,
|
||||
private val isEnteringText: StateFlow<Boolean>,
|
||||
private val initialTab: MediaKeyboardTab?
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return MediaKeyboardViewModel(repository, isEnteringText, initialTab) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.screens
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Whether the media keyboard is (or is settling to) full height. Bottom rails re-show themselves
|
||||
* when it expands.
|
||||
*/
|
||||
val LocalMediaKeyboardExpanded: ProvidableCompositionLocal<Boolean> = compositionLocalOf { false }
|
||||
|
||||
private val SCROLL_DIRECTION_THRESHOLD = 4.dp
|
||||
|
||||
/**
|
||||
* Lays out [content] with a [rail] pinned to the bottom of this layout.
|
||||
*
|
||||
* Like browser chrome, the rail slides away when the content is scrolled down and returns when it
|
||||
* is scrolled back up or the keyboard grows to full height.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PinnedRailLayout(
|
||||
rail: (@Composable () -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val expanded = LocalMediaKeyboardExpanded.current
|
||||
|
||||
var measuredRailHeight by remember { mutableIntStateOf(0) }
|
||||
var railHidden by remember { mutableStateOf(false) }
|
||||
|
||||
val scrollThresholdPx = with(density) { SCROLL_DIRECTION_THRESHOLD.toPx() }
|
||||
val scrollConnection = remember(scrollThresholdPx) {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
|
||||
if (source == NestedScrollSource.UserInput) {
|
||||
val delta = if (consumed.y != 0f) consumed.y else available.y
|
||||
if (delta < -scrollThresholdPx) {
|
||||
railHidden = true
|
||||
} else if (delta > scrollThresholdPx) {
|
||||
railHidden = false
|
||||
}
|
||||
}
|
||||
return Offset.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(expanded) {
|
||||
if (expanded) {
|
||||
railHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
val railHeight = if (rail != null) measuredRailHeight else 0
|
||||
val railHiddenFraction by animateFloatAsState(
|
||||
targetValue = if (railHidden) 1f else 0f,
|
||||
label = "railHiddenFraction"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollConnection)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = with(density) { (railHeight * (1f - railHiddenFraction)).toDp() })
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
if (rail != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { measuredRailHeight = it.height }
|
||||
.graphicsLayer { translationY = railHeight * railHiddenFraction }
|
||||
) {
|
||||
rail()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.screens.emoji
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.signal.core.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.mediakeyboard.MediaKeyboardAction
|
||||
import org.signal.mediakeyboard.MediaKeyboardState
|
||||
import org.signal.mediakeyboard.MediaKeyboardTab
|
||||
import org.signal.mediakeyboard.data.EmojiCategoryPage
|
||||
import org.signal.mediakeyboard.data.EmojiKeyboardCategory
|
||||
import org.signal.mediakeyboard.data.EmojiKeyboardRepository
|
||||
|
||||
class EmojiPageViewModel(
|
||||
private val repository: EmojiKeyboardRepository,
|
||||
private val parentState: StateFlow<MediaKeyboardState>,
|
||||
private val onAction: (MediaKeyboardAction) -> Unit
|
||||
) : EventDrivenViewModel<EmojiPageScreenEvents>(TAG, shouldLogEvents = false) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(EmojiPageViewModel::class)
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(EmojiPageState())
|
||||
val state: StateFlow<EmojiPageState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
onEvent(EmojiPageScreenEvents.Initialize)
|
||||
parentState
|
||||
.onEach { onEvent(EmojiPageScreenEvents.ParentStateChanged(it)) }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override suspend fun processEvent(event: EmojiPageScreenEvents) {
|
||||
applyEvent(_state.value, event) { _state.value = it }
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
suspend fun applyEvent(
|
||||
state: EmojiPageState,
|
||||
event: EmojiPageScreenEvents,
|
||||
stateEmitter: (EmojiPageState) -> Unit
|
||||
) {
|
||||
when (event) {
|
||||
is EmojiPageScreenEvents.Initialize -> {
|
||||
val pages = buildPages()
|
||||
stateEmitter(
|
||||
state.copy(
|
||||
pages = pages,
|
||||
preferredVariations = repository.getPreferredVariations(),
|
||||
selectedCategory = state.selectedCategory ?: pages.firstOrNull()?.category
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is EmojiPageScreenEvents.ParentStateChanged -> {
|
||||
stateEmitter(applyParentState(state, event.parentState))
|
||||
}
|
||||
|
||||
is EmojiPageScreenEvents.CategorySelected -> {
|
||||
stateEmitter(state.copy(selectedCategory = event.category, scrollTarget = event.category))
|
||||
}
|
||||
|
||||
is EmojiPageScreenEvents.VisibleCategoryChanged -> {
|
||||
if (state.scrollTarget == null && state.selectedCategory != event.category) {
|
||||
stateEmitter(state.copy(selectedCategory = event.category))
|
||||
}
|
||||
}
|
||||
|
||||
is EmojiPageScreenEvents.ScrollTargetConsumed -> {
|
||||
stateEmitter(state.copy(scrollTarget = null))
|
||||
}
|
||||
|
||||
is EmojiPageScreenEvents.EmojiClicked -> {
|
||||
val display = state.displayEmoji(event.emoji)
|
||||
repository.onEmojiUsed(display)
|
||||
onAction(MediaKeyboardAction.EmojiSelected(display))
|
||||
}
|
||||
|
||||
is EmojiPageScreenEvents.EmojiLongPressed -> {
|
||||
if (event.emoji.hasVariations) {
|
||||
stateEmitter(state.copy(variationSelector = EmojiPageState.VariationSelectorTarget(event.cellKey, event.emoji)))
|
||||
}
|
||||
}
|
||||
|
||||
is EmojiPageScreenEvents.VariationSelected -> {
|
||||
repository.setPreferredVariation(event.emoji.canonical, event.variation)
|
||||
repository.onEmojiUsed(event.variation)
|
||||
onAction(MediaKeyboardAction.EmojiSelected(event.variation))
|
||||
stateEmitter(
|
||||
state.copy(
|
||||
preferredVariations = repository.getPreferredVariations(),
|
||||
variationSelector = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is EmojiPageScreenEvents.VariationSelectorDismissed -> {
|
||||
stateEmitter(state.copy(variationSelector = null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyParentState(state: EmojiPageState, parent: MediaKeyboardState): EmojiPageState {
|
||||
val searching = parent.searchActive && parent.selectedTab == MediaKeyboardTab.EMOJI
|
||||
|
||||
return if (!searching) {
|
||||
if (state.searchResults != null) {
|
||||
state.copy(searchResults = null, searchQuery = "", pages = buildPages())
|
||||
} else {
|
||||
state
|
||||
}
|
||||
} else if (parent.searchQuery != state.searchQuery || state.searchResults == null) {
|
||||
// Bail if a newer query has already arrived.
|
||||
if (parent.searchQuery != parentState.value.searchQuery) {
|
||||
state
|
||||
} else {
|
||||
val results = if (parent.searchQuery.isBlank()) {
|
||||
repository.getRecentEmoji()
|
||||
} else {
|
||||
repository.search(parent.searchQuery)
|
||||
}
|
||||
state.copy(searchResults = results, searchQuery = parent.searchQuery)
|
||||
}
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun buildPages(): List<EmojiCategoryPage> {
|
||||
val recents = repository.getRecentEmoji()
|
||||
return buildList {
|
||||
if (recents.isNotEmpty()) {
|
||||
add(EmojiCategoryPage(EmojiKeyboardCategory.RECENTS, recents))
|
||||
}
|
||||
addAll(repository.getEmojiPages())
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val repository: EmojiKeyboardRepository,
|
||||
private val parentState: StateFlow<MediaKeyboardState>,
|
||||
private val onAction: (MediaKeyboardAction) -> Unit
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return EmojiPageViewModel(repository, parentState, onAction) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediakeyboard.screens.sticker
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.signal.core.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.mediakeyboard.MediaKeyboardAction
|
||||
import org.signal.mediakeyboard.data.StickerKeyboardRepository
|
||||
|
||||
class StickerPageViewModel(
|
||||
private val repository: StickerKeyboardRepository,
|
||||
private val onAction: (MediaKeyboardAction) -> Unit
|
||||
) : EventDrivenViewModel<StickerPageScreenEvents>(TAG, shouldLogEvents = false) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(StickerPageViewModel::class)
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(StickerPageState(allowAnimation = repository.allowStickerAnimation))
|
||||
val state: StateFlow<StickerPageState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
onEvent(StickerPageScreenEvents.Initialize)
|
||||
repository.observeStickerPacks()
|
||||
.onEach { onEvent(StickerPageScreenEvents.PacksUpdated(it)) }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override suspend fun processEvent(event: StickerPageScreenEvents) {
|
||||
applyEvent(_state.value, event) { _state.value = it }
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
suspend fun applyEvent(
|
||||
state: StickerPageState,
|
||||
event: StickerPageScreenEvents,
|
||||
stateEmitter: (StickerPageState) -> Unit
|
||||
) {
|
||||
when (event) {
|
||||
is StickerPageScreenEvents.Initialize -> Unit
|
||||
|
||||
is StickerPageScreenEvents.PacksUpdated -> {
|
||||
val selected = state.selectedPackId?.takeIf { id -> event.packs.any { it.id == id } }
|
||||
?: event.packs.firstOrNull()?.id
|
||||
stateEmitter(state.copy(packs = event.packs, selectedPackId = selected))
|
||||
}
|
||||
|
||||
is StickerPageScreenEvents.PackSelected -> {
|
||||
stateEmitter(state.copy(selectedPackId = event.packId, scrollTargetPackId = event.packId))
|
||||
}
|
||||
|
||||
is StickerPageScreenEvents.VisiblePackChanged -> {
|
||||
if (state.scrollTargetPackId == null && state.selectedPackId != event.packId) {
|
||||
stateEmitter(state.copy(selectedPackId = event.packId))
|
||||
}
|
||||
}
|
||||
|
||||
is StickerPageScreenEvents.ScrollTargetConsumed -> {
|
||||
stateEmitter(state.copy(scrollTargetPackId = null))
|
||||
}
|
||||
|
||||
is StickerPageScreenEvents.StickerClicked -> {
|
||||
repository.onStickerUsed(event.sticker)
|
||||
onAction(MediaKeyboardAction.StickerSelected(event.sticker))
|
||||
}
|
||||
|
||||
is StickerPageScreenEvents.SearchClicked -> {
|
||||
onAction(MediaKeyboardAction.StickerSearchClicked)
|
||||
}
|
||||
|
||||
is StickerPageScreenEvents.ViewStickerPackClicked -> {
|
||||
onAction(MediaKeyboardAction.ViewStickerPackClicked(event.packId, event.packKey))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val repository: StickerKeyboardRepository,
|
||||
private val onAction: (MediaKeyboardAction) -> Unit
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return StickerPageViewModel(repository, onAction) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user