Replace main activity xml with AppScaffold.

This commit is contained in:
Alex Hart
2025-03-25 14:01:20 -03:00
committed by Cody Henthorne
parent 8053d567f2
commit 276285ebef
8 changed files with 149 additions and 39 deletions

View File

@@ -12,7 +12,15 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.compose.AndroidFragment
import androidx.fragment.compose.rememberFragmentState
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getSerializableCompat import org.signal.core.util.getSerializableCompat
@@ -29,11 +37,14 @@ import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSh
import org.thoughtcrime.securesms.conversationlist.RestoreCompleteBottomSheetDialog import org.thoughtcrime.securesms.conversationlist.RestoreCompleteBottomSheetDialog
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.main.MainActivityListHostFragment
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
import org.thoughtcrime.securesms.notifications.VitalsViewModel import org.thoughtcrime.securesms.notifications.VitalsViewModel
import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.stories.tabs.ConversationListTab import org.thoughtcrime.securesms.stories.tabs.ConversationListTab
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsFragment
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel
import org.thoughtcrime.securesms.util.AppStartup import org.thoughtcrime.securesms.util.AppStartup
import org.thoughtcrime.securesms.util.CachedInflater import org.thoughtcrime.securesms.util.CachedInflater
@@ -42,6 +53,7 @@ import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.SplashScreenUtil import org.thoughtcrime.securesms.util.SplashScreenUtil
import org.thoughtcrime.securesms.util.WindowUtil import org.thoughtcrime.securesms.util.WindowUtil
import org.thoughtcrime.securesms.util.viewModel import org.thoughtcrime.securesms.util.viewModel
import org.thoughtcrime.securesms.window.AppScaffold
class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, MainNavigator.NavigatorProvider { class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, MainNavigator.NavigatorProvider {
@@ -85,7 +97,39 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
AppStartup.getInstance().onCriticalRenderEventStart() AppStartup.getInstance().onCriticalRenderEventStart()
super.onCreate(savedInstanceState, ready) super.onCreate(savedInstanceState, ready)
setContentView(R.layout.main_activity) setContent {
val navState = rememberFragmentState()
val listHostState = rememberFragmentState()
val detailLocation by navigator.viewModel.detailLocation.collectAsStateWithLifecycle()
LaunchedEffect(detailLocation) {
if (detailLocation is MainNavigationDetailLocation.Conversation) {
startActivity((detailLocation as MainNavigationDetailLocation.Conversation).intent)
overridePendingTransition(R.anim.slide_from_end, R.anim.fade_scale_out)
}
}
AppScaffold(
bottomNavContent = {
AndroidFragment(
clazz = ConversationListTabsFragment::class.java,
fragmentState = navState
)
},
navRailContent = {
AndroidFragment(
clazz = ConversationListTabsFragment::class.java,
fragmentState = navState
)
}
) {
AndroidFragment(
clazz = MainActivityListHostFragment::class.java,
fragmentState = listHostState,
modifier = Modifier.fillMaxSize()
)
}
}
val content: View = findViewById(android.R.id.content) val content: View = findViewById(android.R.id.content)
content.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { content.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {

View File

@@ -3,15 +3,20 @@ package org.thoughtcrime.securesms;
import android.app.Activity; import android.app.Activity;
import android.content.Intent; import android.content.Intent;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.ViewModelProvider;
import androidx.lifecycle.viewmodel.internal.ViewModelProviders;
import org.signal.core.util.concurrent.LifecycleDisposable; import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity; import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.conversation.ConversationIntents; import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity; import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation;
import org.thoughtcrime.securesms.main.MainNavigationViewModel;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
@@ -20,8 +25,10 @@ public class MainNavigator {
public static final int REQUEST_CONFIG_CHANGES = 901; public static final int REQUEST_CONFIG_CHANGES = 901;
private final AppCompatActivity activity; private final AppCompatActivity activity;
private final LifecycleDisposable lifecycleDisposable; private final LifecycleDisposable lifecycleDisposable;
private MainNavigationViewModel viewModel;
public MainNavigator(@NonNull AppCompatActivity activity) { public MainNavigator(@NonNull AppCompatActivity activity) {
this.activity = activity; this.activity = activity;
@@ -30,6 +37,15 @@ public class MainNavigator {
lifecycleDisposable.bindTo(activity); lifecycleDisposable.bindTo(activity);
} }
@MainThread
public @NonNull MainNavigationViewModel getViewModel() {
if (viewModel == null) {
viewModel = new ViewModelProvider(activity).get(MainNavigationViewModel.class);
}
return viewModel;
}
public static MainNavigator get(@NonNull Activity activity) { public static MainNavigator get(@NonNull Activity activity) {
if (!(activity instanceof MainActivity)) { if (!(activity instanceof MainActivity)) {
throw new IllegalArgumentException("Activity must be an instance of MainActivity!"); throw new IllegalArgumentException("Activity must be an instance of MainActivity!");
@@ -57,10 +73,7 @@ public class MainNavigator {
.map(builder -> builder.withDistributionType(distributionType) .map(builder -> builder.withDistributionType(distributionType)
.withStartingPosition(startingPosition) .withStartingPosition(startingPosition)
.build()) .build())
.subscribe(intent -> { .subscribe(intent -> viewModel.goTo(new MainNavigationDetailLocation.Conversation(intent)));
activity.startActivity(intent);
activity.overridePendingTransition(R.anim.slide_from_end, R.anim.fade_scale_out);
});
lifecycleDisposable.add(disposable); lifecycleDisposable.add(disposable);
} }

View File

@@ -0,0 +1,16 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import android.content.Intent
/**
* Describes which content to display in the detail view.
*/
sealed interface MainNavigationDetailLocation {
data object Empty : MainNavigationDetailLocation
data class Conversation(val intent: Intent) : MainNavigationDetailLocation
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
class MainNavigationViewModel : ViewModel() {
private val detailLocationFlow = MutableStateFlow<MainNavigationDetailLocation>(MainNavigationDetailLocation.Empty)
val detailLocation: StateFlow<MainNavigationDetailLocation> = detailLocationFlow
fun goTo(location: MainNavigationDetailLocation) {
detailLocationFlow.update { location }
}
}

View File

@@ -8,7 +8,10 @@ import androidx.fragment.app.viewModels
import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.main.MainNavigationBar import org.thoughtcrime.securesms.main.MainNavigationBar
import org.thoughtcrime.securesms.main.MainNavigationDestination import org.thoughtcrime.securesms.main.MainNavigationDestination
import org.thoughtcrime.securesms.main.MainNavigationRail
import org.thoughtcrime.securesms.main.MainNavigationState import org.thoughtcrime.securesms.main.MainNavigationState
import org.thoughtcrime.securesms.window.Navigation
import org.thoughtcrime.securesms.window.WindowSizeClass
/** /**
* Displays the "Chats" and "Stories" tab to a user. * Displays the "Chats" and "Stories" tab to a user.
@@ -37,16 +40,28 @@ class ConversationListTabsFragment : ComposeFragment() {
} }
if (state.visibilityState.isVisible()) { if (state.visibilityState.isVisible()) {
MainNavigationBar( val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
state = navState, val onDestinationSelected: (MainNavigationDestination) -> Unit = remember {
onDestinationSelected = { {
when (it) { when (it) {
MainNavigationDestination.CHATS -> viewModel.onChatsSelected() MainNavigationDestination.CHATS -> viewModel.onChatsSelected()
MainNavigationDestination.CALLS -> viewModel.onCallsSelected() MainNavigationDestination.CALLS -> viewModel.onCallsSelected()
MainNavigationDestination.STORIES -> viewModel.onStoriesSelected() MainNavigationDestination.STORIES -> viewModel.onStoriesSelected()
} }
} }
) }
if (windowSizeClass.navigation == Navigation.BAR) {
MainNavigationBar(
state = navState,
onDestinationSelected = onDestinationSelected
)
} else {
MainNavigationRail(
state = navState,
onDestinationSelected = onDestinationSelected
)
}
} }
} }

View File

@@ -1121,5 +1121,12 @@ object RemoteConfig {
hotSwappable = true hotSwappable = true
) )
/** Whether to allow different WindowSizeClasses to be used to determine screen layout */
val largeScreenUi: Boolean by remoteBoolean(
key = "android.largeScreenUI",
defaultValue = false,
hotSwappable = false
)
// endregion // endregion
} }

View File

@@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -30,6 +31,7 @@ import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.main.MainNavigationBar import org.thoughtcrime.securesms.main.MainNavigationBar
import org.thoughtcrime.securesms.main.MainNavigationRail import org.thoughtcrime.securesms.main.MainNavigationRail
import org.thoughtcrime.securesms.main.MainNavigationState import org.thoughtcrime.securesms.main.MainNavigationState
import org.thoughtcrime.securesms.util.RemoteConfig
enum class Navigation { enum class Navigation {
RAIL, RAIL,
@@ -39,6 +41,10 @@ enum class Navigation {
/** /**
* Describes the size of screen we are displaying, and what components should be displayed. * Describes the size of screen we are displaying, and what components should be displayed.
* *
* Screens should utilize this class by convention instead of calling [currentWindowAdaptiveInfo]
* themselves, as this class includes checks with [RemoteConfig] to ensure we're allowed to display
* content in different screen sizes.
*
* https://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes * https://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes
*/ */
enum class WindowSizeClass( enum class WindowSizeClass(
@@ -54,9 +60,20 @@ enum class WindowSizeClass(
companion object { companion object {
@Composable @Composable
fun rememberWindowSizeClass(): WindowSizeClass { fun rememberWindowSizeClass(): WindowSizeClass {
val wsc = currentWindowAdaptiveInfo().windowSizeClass
val orientation = LocalConfiguration.current.orientation val orientation = LocalConfiguration.current.orientation
if (!LocalInspectionMode.current && !RemoteConfig.largeScreenUi) {
return when (orientation) {
Configuration.ORIENTATION_PORTRAIT, Configuration.ORIENTATION_UNDEFINED, Configuration.ORIENTATION_SQUARE -> {
COMPACT_PORTRAIT
}
Configuration.ORIENTATION_LANDSCAPE -> COMPACT_LANDSCAPE
else -> error("Unexpected orientation: $orientation")
}
}
val wsc = currentWindowAdaptiveInfo().windowSizeClass
return remember(orientation, wsc) { return remember(orientation, wsc) {
when (orientation) { when (orientation) {
Configuration.ORIENTATION_PORTRAIT, Configuration.ORIENTATION_UNDEFINED, Configuration.ORIENTATION_SQUARE -> { Configuration.ORIENTATION_PORTRAIT, Configuration.ORIENTATION_UNDEFINED, Configuration.ORIENTATION_SQUARE -> {
@@ -88,10 +105,10 @@ enum class WindowSizeClass(
*/ */
@Composable @Composable
fun AppScaffold( fun AppScaffold(
listContent: @Composable () -> Unit, detailContent: @Composable () -> Unit = {},
detailContent: @Composable () -> Unit, navRailContent: @Composable () -> Unit = {},
navRailContent: @Composable () -> Unit, bottomNavContent: @Composable () -> Unit = {},
bottomNavContent: @Composable () -> Unit listContent: @Composable () -> Unit
) { ) {
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass() val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()

View File

@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.fragment.app.FragmentContainerView
android:name="org.thoughtcrime.securesms.main.MainActivityListHostFragment"
android:id="@+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
tools:context=".MainActivity" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/conversation_list_tabs"
android:name="org.thoughtcrime.securesms.stories.tabs.ConversationListTabsFragment"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>