From cdb2770a53d4a293e742c0f65d3b1e0eb321205d Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 28 Oct 2026 03:54:36 -0500 Subject: [PATCH 1/3] fix(mobile): use a native split button for Android merges --- .../t3-native-controls/android/build.gradle | 3 +- .../t3nativecontrols/T3SplitButtonModule.kt | 204 ++++++++++++++++++ .../src/main/res/drawable/t3_check.xml | 4 + .../src/main/res/drawable/t3_chevron_down.xml | 4 + .../expo-module.config.json | 1 + .../PullRequestMergeButton.android.tsx | 71 ++++++ .../pull-requests/PullRequestMergeButton.tsx | 75 +++++++ .../PullRequestMergeControls.tsx | 141 +++--------- 8 files changed, 396 insertions(+), 107 deletions(-) create mode 100644 apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3SplitButtonModule.kt create mode 100644 apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_check.xml create mode 100644 apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_chevron_down.xml create mode 100644 apps/mobile/src/features/pull-requests/PullRequestMergeButton.android.tsx create mode 100644 apps/mobile/src/features/pull-requests/PullRequestMergeButton.tsx diff --git a/apps/mobile/modules/t3-native-controls/android/build.gradle b/apps/mobile/modules/t3-native-controls/android/build.gradle index 9ecf11f68dbcf..68d2238789768 100644 --- a/apps/mobile/modules/t3-native-controls/android/build.gradle +++ b/apps/mobile/modules/t3-native-controls/android/build.gradle @@ -6,7 +6,7 @@ buildscript { } apply plugin: 'com.android.library' -apply plugin: 'org.jetbrains.kotlin.android' +apply plugin: 'expo-module-gradle-plugin' apply plugin: 'org.jetbrains.kotlin.plugin.compose' group = 'com.t3tools.nativecontrols' @@ -25,6 +25,7 @@ android { dependencies { implementation project(':expo-modules-core') + implementation project(':expo-ui') implementation 'androidx.compose.ui:ui-android:1.10.6' implementation 'androidx.activity:activity:1.8.2' implementation 'androidx.compose.material3:material3-android:1.5.0-alpha17' diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3SplitButtonModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3SplitButtonModule.kt new file mode 100644 index 0000000000000..9db3adc677c7d --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3SplitButtonModule.kt @@ -0,0 +1,204 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) + +package expo.modules.t3nativecontrols + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors as ComposeButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.SplitButtonDefaults +import androidx.compose.material3.SplitButtonLayout +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.records.Field +import expo.modules.kotlin.records.Record +import expo.modules.kotlin.types.OptimizedRecord +import expo.modules.kotlin.views.ComposeProps +import expo.modules.kotlin.views.OptimizedComposeProps +import expo.modules.ui.ExpoUIView +import expo.modules.ui.GenericEventPayload1 +import expo.modules.ui.button.ButtonColors +import expo.modules.ui.compose + +@OptimizedRecord +class SplitButtonAction : Record { + @Field val id: String = "" + + @Field val title: String = "" + + @Field val selected: Boolean = false + + @Field val enabled: Boolean = true +} + +@OptimizedComposeProps +data class SplitButtonProps( + val label: String = "", + val accessibilityLabel: String = "", + val enabled: Boolean = true, + val loading: Boolean = false, + val menuTitle: String = "", + val actions: List = emptyList(), + val fontSize: Float = 14f, + val lineHeight: Float = 19f, + val menuFontSize: Float = 16f, + val menuLineHeight: Float = 23f, + val scale: Float = 1f, + val colors: ButtonColors = ButtonColors() +) : ComposeProps + +class T3SplitButtonModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3SplitButton") + ExpoUIView("SplitButton") { + val onButtonPressed by Event() + val onActionSelected by Event>() + Content { props -> + splitButtonContent(props, { + onButtonPressed(Unit) + }, { onActionSelected(GenericEventPayload1(it)) }) + } + } + } +} + +@Composable +private fun splitButtonContent( + props: SplitButtonProps, + onClick: () -> Unit, + onSelect: (String) -> Unit +) { + val colors = ButtonDefaults.buttonColors( + containerColor = props.colors.containerColor.compose, + contentColor = props.colors.contentColor.compose, + disabledContainerColor = props.colors.disabledContainerColor.compose, + disabledContentColor = props.colors.disabledContentColor.compose + ) + val enabled = props.enabled && !props.loading + val height = (48 * props.scale).dp + BoxWithConstraints { + val trailingWidth = (48 * props.scale).dp + val leadingWidth = if (props.actions.isEmpty()) { + maxWidth + } else { + (maxWidth - trailingWidth - SplitButtonDefaults.Spacing).coerceAtLeast(0.dp) + } + val leadingModifier = Modifier.width(leadingWidth).heightIn(min = height) + .semantics { contentDescription = props.accessibilityLabel } + if (props.actions.isEmpty()) { + Button(onClick = onClick, enabled = enabled, colors = colors, modifier = leadingModifier) { + splitButtonLabel(props) + } + } else { + SplitButtonLayout( + leadingButton = { + SplitButtonDefaults.LeadingButton( + onClick = onClick, + enabled = enabled, + colors = colors, + modifier = leadingModifier + ) { splitButtonLabel(props) } + }, + trailingButton = { splitButtonMenu(props, colors, onSelect) } + ) + } + } +} + +@Composable +private fun splitButtonLabel(props: SplitButtonProps) { + if (props.loading) { + LoadingIndicator( + modifier = Modifier.size((18 * props.scale).dp), + color = props.colors.disabledContentColor.compose + ) + Spacer(Modifier.width((8 * props.scale).dp)) + } + Text( + props.label, + fontSize = props.fontSize.sp, + lineHeight = props.lineHeight.sp, + fontWeight = FontWeight.Medium + ) +} + +@Composable +private fun splitButtonMenu( + props: SplitButtonProps, + colors: ComposeButtonColors, + onSelect: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + Box(propagateMinConstraints = true) { + SplitButtonDefaults.TrailingButton( + checked = expanded, + onCheckedChange = { expanded = it }, + colors = colors, + modifier = Modifier.width((48 * props.scale).dp).heightIn(min = (48 * props.scale).dp) + .semantics { contentDescription = props.menuTitle } + ) { + Icon( + painter = painterResource(R.drawable.t3_chevron_down), + contentDescription = null, + modifier = Modifier.size((24 * props.scale).dp) + .graphicsLayer { rotationZ = if (expanded) 180f else 0f } + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.width((250 * props.scale).dp) + ) { + props.actions.forEach { action -> + DropdownMenuItem( + text = { + Text( + action.title, + fontSize = props.menuFontSize.sp, + lineHeight = props.menuLineHeight.sp + ) + }, + enabled = action.enabled, + modifier = Modifier.heightIn(min = (48 * props.scale).dp) + .semantics { selected = action.selected }, + trailingIcon = { + if (action.selected) { + Icon( + painterResource(R.drawable.t3_check), + contentDescription = null + ) + } + }, + onClick = { + expanded = false + onSelect(action.id) + } + ) + } + } + } +} diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_check.xml b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_check.xml new file mode 100644 index 0000000000000..c52ebfe9fb7a8 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_check.xml @@ -0,0 +1,4 @@ + + + diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_chevron_down.xml b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_chevron_down.xml new file mode 100644 index 0000000000000..98082f5030ac5 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_chevron_down.xml @@ -0,0 +1,4 @@ + + + diff --git a/apps/mobile/modules/t3-native-controls/expo-module.config.json b/apps/mobile/modules/t3-native-controls/expo-module.config.json index 22fcb74a36cfb..3fb47cdffb1a6 100644 --- a/apps/mobile/modules/t3-native-controls/expo-module.config.json +++ b/apps/mobile/modules/t3-native-controls/expo-module.config.json @@ -10,6 +10,7 @@ "android": { "modules": [ "expo.modules.t3nativecontrols.T3NativeControlsModule", + "expo.modules.t3nativecontrols.T3SplitButtonModule", "expo.modules.t3nativecontrols.T3WorkspaceNavigationModule", "expo.modules.t3nativecontrols.T3KeyboardCommandsModule", "expo.modules.t3nativecontrols.T3FoldPostureModule" diff --git a/apps/mobile/src/features/pull-requests/PullRequestMergeButton.android.tsx b/apps/mobile/src/features/pull-requests/PullRequestMergeButton.android.tsx new file mode 100644 index 0000000000000..7ae5089aba0d2 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/PullRequestMergeButton.android.tsx @@ -0,0 +1,71 @@ +import { requireNativeView } from "expo"; +import type { NativeSyntheticEvent } from "react-native"; +import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; +import { NativeThemeHost } from "../../components/ui/NativeThemeHost.android"; +import type { PullRequestMergeButtonProps } from "./PullRequestMergeButton"; +import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; +import { useControlTheme } from "../../components/ui/useControlTheme"; + +const NativeSplitButton = requireNativeView<{ + label: string; + accessibilityLabel: string; + enabled: boolean; + loading: boolean; + menuTitle: string; + actions: { id: string; title: string; selected: boolean; enabled: boolean }[]; + fontSize: number; + lineHeight: number; + menuFontSize: number; + menuLineHeight: number; + scale: number; + colors: { + containerColor: string; + contentColor: string; + disabledContainerColor: string; + disabledContentColor: string; + }; + onButtonPressed: () => void; + onActionSelected: (event: NativeSyntheticEvent<{ value: string }>) => void; +}>("T3SplitButton", "SplitButton"); + +export function PullRequestMergeButton(props: PullRequestMergeButtonProps) { + const { colors } = useControlTheme(); + const typography = useScaledTextRole("footnote"); + const menuTypography = useScaledTextRole("body"); + const { scale } = useAndroidControlSizing(); + return ( + + ({ + id: action.id, + title: action.title ?? "", + selected: action.state === "on", + enabled: !action.attributes?.disabled, + }))} + {...typography} + menuFontSize={menuTypography.fontSize} + menuLineHeight={menuTypography.lineHeight} + scale={scale} + colors={{ + containerColor: colors["--color-primary"], + contentColor: colors["--color-primary-foreground"], + disabledContainerColor: colors["--color-subtle-strong"], + disabledContentColor: colors["--color-foreground-muted"], + }} + onButtonPressed={props.onPress} + onActionSelected={({ nativeEvent }) => props.onSelectAction(nativeEvent.value)} + /> + + ); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestMergeButton.tsx b/apps/mobile/src/features/pull-requests/PullRequestMergeButton.tsx new file mode 100644 index 0000000000000..af57d68950241 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/PullRequestMergeButton.tsx @@ -0,0 +1,75 @@ +import { PullRequestGlyph } from "./pullRequestGlyphs"; +import type { MenuAction } from "@react-native-menu/menu"; +import { Pressable, View } from "react-native"; +import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; +import { AppText } from "../../components/ui/AppText"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { Menu } from "../../components/ui/Menu"; + +export interface PullRequestMergeButtonProps { + readonly label: string; + readonly accessibilityLabel?: string; + readonly disabled: boolean; + readonly loading: boolean; + readonly onPress: () => void; + readonly menuTitle: string; + readonly actions: (Pick & { + readonly id: string; + readonly attributes?: { readonly disabled?: boolean }; + })[]; + readonly onSelectAction: (id: string) => void; +} + +export function PullRequestMergeButton(props: PullRequestMergeButtonProps) { + const active = !props.disabled; + return ( + + + {props.loading ? ( + + ) : ( + + )} + + {props.label} + + + {props.actions.length > 0 ? ( + props.onSelectAction(nativeEvent.event)} + > + + + + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx b/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx index b784bdb4eb427..302d88d5648a9 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx @@ -1,4 +1,3 @@ -import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import * as Dialog from "../../components/ui/Dialog"; import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; @@ -6,12 +5,10 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import type { PullRequestDetail, PullRequestMergeMethod } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; import { useRef, useState } from "react"; -import { Platform, Pressable, View } from "react-native"; +import { View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; -import { SymbolView } from "../../components/ui/AppSymbol"; -import { Menu } from "../../components/ui/Menu"; import { Button } from "../../components/ui/Button"; -import { IconButton } from "../../components/ui/IconButton"; +import { PullRequestMergeButton } from "./PullRequestMergeButton"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { composerPullRequests } from "../../state/pull-requests"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -27,7 +24,6 @@ import { pullRequestStackMerge, singlePullRequestMergeAllowed } from "./pullRequ import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; import { usePullRequestPermissions } from "./usePullRequestPermissions"; import type { PullRequestStackState } from "./usePullRequestStack"; -import { PullRequestGlyph } from "./pullRequestGlyphs"; export function PullRequestMergeControls(props: { readonly pr: PullRequestDetail; @@ -169,107 +165,40 @@ export function PullRequestMergeControls(props: { }; return ( - - {Platform.OS === "android" ? ( - -