Move additional fragments to core UI.

This commit is contained in:
Alex Hart
2026-02-06 14:50:53 -04:00
committed by Greyson Parrelli
parent 8d749c404f
commit 62d951b438
112 changed files with 243 additions and 155 deletions

View File

@@ -43,6 +43,7 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
api(libs.google.zxing.core)
api(libs.material.material)
api(libs.androidx.window.window)
api(libs.accompanist.permissions)
// JUnit is used by test fixtures

View File

@@ -20,7 +20,15 @@ object CoreUiDependencies {
val isIncognitoKeyboardEnabled: Boolean
get() = _provider.provideIsIncognitoKeyboardEnabled()
val isScreenSecurityEnabled: Boolean
get() = _provider.provideIsScreenSecurityEnabled()
val forceSplitPane: Boolean
get() = _provider.provideForceSplitPane()
interface Provider {
fun provideIsIncognitoKeyboardEnabled(): Boolean
fun provideIsScreenSecurityEnabled(): Boolean
fun provideForceSplitPane(): Boolean
}
}

View File

@@ -0,0 +1,93 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui
import android.app.Dialog
import android.content.res.ColorStateList
import android.graphics.Color
import android.os.Bundle
import android.view.ContextThemeWrapper
import android.view.View
import androidx.annotation.ColorInt
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import org.signal.core.ui.util.ThemeUtil
import org.signal.core.util.DimensionUnit
import com.google.android.material.R as MaterialR
/**
* Forces rounded corners on BottomSheet.
*
* Expects [R.attr.fixedRoundedCornerBottomSheetStyle] to be defined in your app theme, pointing to a
* style that extends [Widget.CoreUi.FixedRoundedCorners]. Subclasses can override [themeResId] to
* use an alternate style.
*/
abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFragment() {
/**
* Sheet corner radius in DP
*/
protected open val cornerRadius: Int
get() = if (resources.getWindowSizeClass().isSplitPane()) {
32
} else {
18
}
protected open val peekHeightPercentage: Float = 0.5f
protected open val themeResId: Int
get() = ThemeUtil.getThemedResourceId(requireContext(), R.attr.fixedRoundedCornerBottomSheetStyle)
@ColorInt
protected var backgroundColor: Int = Color.TRANSPARENT
private lateinit var dialogBackground: MaterialShapeDrawable
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, themeResId)
}
override fun onResume() {
super.onResume()
dialog?.window?.initializeScreenshotSecurity()
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
dialog.behavior.peekHeight = (resources.displayMetrics.heightPixels * peekHeightPercentage).toInt()
val cornerRadiusPx = DimensionUnit.DP.toPixels(cornerRadius.toFloat())
val shapeAppearanceModel = ShapeAppearanceModel.builder()
.setTopLeftCorner(CornerFamily.ROUNDED, cornerRadiusPx)
.setTopRightCorner(CornerFamily.ROUNDED, cornerRadiusPx)
.build()
dialogBackground = MaterialShapeDrawable(shapeAppearanceModel)
val bottomSheetStyle = ThemeUtil.getThemedResourceId(ContextThemeWrapper(requireContext(), themeResId), MaterialR.attr.bottomSheetStyle)
backgroundColor = ThemeUtil.getThemedColor(ContextThemeWrapper(requireContext(), bottomSheetStyle), MaterialR.attr.backgroundTint)
dialogBackground.fillColor = ColorStateList.valueOf(backgroundColor)
dialog.behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (bottomSheet.background !== dialogBackground) {
bottomSheet.background = dialogBackground
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
})
return dialog
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui
import android.view.Window
import android.view.WindowManager
/**
* Initializes screenshot security on the window based on user preferences.
*/
fun Window.initializeScreenshotSecurity() {
if (CoreUiDependencies.isScreenSecurityEnabled) {
addFlags(WindowManager.LayoutParams.FLAG_SECURE)
} else {
clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui
import android.content.res.Resources
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowSizeClass
import androidx.window.core.layout.computeWindowSizeClass
val WindowSizeClass.listPaneDefaultPreferredWidth: Dp get() = if (isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND)) 416.dp else 316.dp
val WindowSizeClass.horizontalPartitionDefaultSpacerSize: Dp get() = 12.dp
val WindowSizeClass.detailPaneMaxContentWidth: Dp get() = 624.dp
val WindowSizeClass.isWidthCompact
get() = !isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)
val WindowSizeClass.isHeightCompact
get() = !isHeightAtLeastBreakpoint(WindowSizeClass.HEIGHT_DP_MEDIUM_LOWER_BOUND)
fun Resources.getWindowSizeClass(): WindowSizeClass {
return WindowSizeClass.BREAKPOINTS_V1.computeWindowSizeClass(
widthDp = displayMetrics.widthPixels / displayMetrics.density,
heightDp = displayMetrics.heightPixels / displayMetrics.density
)
}
/**
* Determines whether the UI should display in split-pane mode based on available screen space.
*/
@JvmOverloads
fun WindowSizeClass.isSplitPane(
forceSplitPane: Boolean = CoreUiDependencies.forceSplitPane
): Boolean {
if (forceSplitPane) {
return true
}
return isAtLeastBreakpoint(
widthDpBreakpoint = WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND,
heightDpBreakpoint = WindowSizeClass.HEIGHT_DP_MEDIUM_LOWER_BOUND
)
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import org.signal.core.ui.FixedRoundedCornerBottomSheetDialogFragment
import org.signal.core.ui.compose.theme.SignalTheme
abstract class ComposeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
protected open val forceDarkTheme = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val isDark = if (forceDarkTheme) {
true
} else {
LocalConfiguration.current.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
}
SignalTheme(isDarkMode = isDark) {
Surface(
shape = RoundedCornerShape(cornerRadius.dp, cornerRadius.dp),
color = SignalTheme.colors.colorSurface1,
contentColor = MaterialTheme.colorScheme.onSurface
) {
SheetContent()
}
}
}
}
}
@Composable
abstract fun SheetContent()
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.DialogFragment
import org.signal.core.ui.R
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.initializeScreenshotSecurity
import org.signal.core.ui.util.ThemeUtil
/**
* Generic Compose-based full screen dialog fragment.
*
* Expects [R.attr.fullScreenDialogStyle] to be defined in your app theme, pointing to a style
* suitable for full screen dialogs.
*/
abstract class ComposeFullScreenDialogFragment : DialogFragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val fullScreenDialogStyle = ThemeUtil.getThemedResourceId(requireContext(), R.attr.fullScreenDialogStyle)
setStyle(STYLE_NO_FRAME, fullScreenDialogStyle)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
SignalTheme {
DialogContent()
}
}
}
}
override fun onResume() {
super.onResume()
requireDialog().window?.initializeScreenshotSecurity()
}
@Composable
abstract fun DialogContent()
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android" >
<translate
android:duration="350"
android:fromYDelta="100%"
android:toYDelta="0%" />
</set>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android" >
<translate
android:duration="350"
android:fromYDelta="0%"
android:toYDelta="100%" />
</set>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="bottom_sheet_corner_size">32dp</dimen>
</resources>

View File

@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr name="theme_type" format="string"/>
<attr name="fullScreenDialogStyle" format="reference"/>
<attr name="fixedRoundedCornerBottomSheetStyle" format="reference"/>
</resources>

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="gutter">16dp</dimen>
<dimen name="bottom_sheet_corner_size">18dp</dimen>
</resources>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Widget.CoreUi.FixedRoundedCorners" parent="ThemeOverlay.Material3.BottomSheetDialog">
<item name="android:windowIsFloating">false</item>
<item name="android:windowSoftInputMode">adjustResize</item>
<item name="bottomSheetStyle">@style/Widget.CoreUi.FixedRoundedCorners.BottomSheet</item>
<item name="android:windowEnterAnimation">@anim/slide_from_bottom</item>
<item name="android:windowExitAnimation">@anim/slide_to_bottom</item>
<item name="android:navigationBarColor" tools:targetApi="lollipop">@color/signal_colorSurface1</item>
</style>
<style name="Widget.CoreUi.FixedRoundedCorners.BottomSheet" parent="Widget.Material3.BottomSheet.Modal">
<item name="shapeAppearanceOverlay">@style/ShapeAppearanceOverlay.Signal.BottomSheet.Rounded</item>
<item name="backgroundTint">@color/signal_colorSurface1</item>
<item name="android:elevation" tools:ignore="NewApi">0dp</item>
</style>
<style name="ShapeAppearanceOverlay.Signal.BottomSheet.Rounded" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSizeTopRight">@dimen/bottom_sheet_corner_size</item>
<item name="cornerSizeTopLeft">@dimen/bottom_sheet_corner_size</item>
<item name="cornerSizeBottomLeft">0dp</item>
<item name="cornerSizeBottomRight">0dp</item>
</style>
</resources>

View File

@@ -22,5 +22,7 @@ class CoreUiDependenciesRule(
val isIncognitoKeyboardEnabled: Boolean
): CoreUiDependencies.Provider {
override fun provideIsIncognitoKeyboardEnabled(): Boolean = isIncognitoKeyboardEnabled
override fun provideIsScreenSecurityEnabled(): Boolean = false
override fun provideForceSplitPane(): Boolean = false
}
}