diff --git a/.github/pr-screenshots/2350-android-navbar/phone/activity-dark.png b/.github/pr-screenshots/2350-android-navbar/phone/activity-dark.png new file mode 100644 index 0000000000..fe6a857a4f Binary files /dev/null and b/.github/pr-screenshots/2350-android-navbar/phone/activity-dark.png differ diff --git a/.github/pr-screenshots/2350-android-navbar/phone/activity-font.png b/.github/pr-screenshots/2350-android-navbar/phone/activity-font.png new file mode 100644 index 0000000000..5c441e32d8 Binary files /dev/null and b/.github/pr-screenshots/2350-android-navbar/phone/activity-font.png differ diff --git a/.github/pr-screenshots/2350-android-navbar/phone/activity-light.png b/.github/pr-screenshots/2350-android-navbar/phone/activity-light.png new file mode 100644 index 0000000000..e43015e376 Binary files /dev/null and b/.github/pr-screenshots/2350-android-navbar/phone/activity-light.png differ diff --git a/.github/pr-screenshots/2350-android-navbar/phone/chats-dark.png b/.github/pr-screenshots/2350-android-navbar/phone/chats-dark.png new file mode 100644 index 0000000000..d833ffe7aa Binary files /dev/null and b/.github/pr-screenshots/2350-android-navbar/phone/chats-dark.png differ diff --git a/.github/pr-screenshots/2350-android-navbar/phone/chats-font.png b/.github/pr-screenshots/2350-android-navbar/phone/chats-font.png new file mode 100644 index 0000000000..a9d7a9e50c Binary files /dev/null and b/.github/pr-screenshots/2350-android-navbar/phone/chats-font.png differ diff --git a/.github/pr-screenshots/2350-android-navbar/phone/chats-light.png b/.github/pr-screenshots/2350-android-navbar/phone/chats-light.png new file mode 100644 index 0000000000..eb5c707168 Binary files /dev/null and b/.github/pr-screenshots/2350-android-navbar/phone/chats-light.png differ diff --git a/.github/pr-screenshots/2350-android-navbar/phone/fab-menu-dark.png b/.github/pr-screenshots/2350-android-navbar/phone/fab-menu-dark.png new file mode 100644 index 0000000000..7537ad2138 Binary files /dev/null and b/.github/pr-screenshots/2350-android-navbar/phone/fab-menu-dark.png differ diff --git a/.github/pr-screenshots/2350-android-navbar/phone/fab-menu-font.png b/.github/pr-screenshots/2350-android-navbar/phone/fab-menu-font.png new file mode 100644 index 0000000000..857fc85a9a Binary files /dev/null and b/.github/pr-screenshots/2350-android-navbar/phone/fab-menu-font.png differ diff --git a/.github/pr-screenshots/2350-android-navbar/phone/fab-menu-light.png b/.github/pr-screenshots/2350-android-navbar/phone/fab-menu-light.png new file mode 100644 index 0000000000..c98b21696f Binary files /dev/null and b/.github/pr-screenshots/2350-android-navbar/phone/fab-menu-light.png differ diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index fa45450fa7..47d64346e0 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -88,8 +88,9 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - **Tabs** remain **Chats** and **Activity**, using the same Expo Router JS tab navigator on all platforms, with the existing route stacks above it. Search arrives with its working N3 slice. - A chat or creation form is pushed above the tabs, hiding the bar. Each list reserves the measured bar height plus safe-area gap; scroll hide/reveal stays on the UI thread. - The floating navigation island sits beside a separate circular **+**, equal-height at default text size. At larger text sizes, the island can grow; on iOS each tab label stays on one line and shrinks to fit (`navigationBar.labelMinScale`) instead of breaking inside a word; the icon-only circle keeps its normal touch size and aligns to the bottom so it does not consume the labels' width. iOS uses real SwiftUI navigation and plus/close buttons. On iOS 26 the selected tab and circular action use native glass button styles over the shared glass island; earlier iOS uses bordered controls. System Reduce Transparency remains authoritative. Native text keeps semantic Dynamic Type. This is app-owned tab presentation, not `NativeTabs` or a second navigation stack (ADR-0235). - - Android retains the opaque OCAT raised rounded rectangle, hairline edge, faint token shadow and selected icon tile. The Activity destination shows the bell **or** the capped pending count, never both, on iOS and Android alike: when Activity is not selected and something is pending, the count replaces the bell; when nothing is pending, or Activity is selected (its feed already shows the items), the bell returns. Android cross-fades the swap. The accessible label always carries the count. Essential labels grow with system text. - - **+** opens a modal action panel in the island position, with close in the circular position. Back, accessibility escape and backdrop only dismiss it. The panel is always a scrollable vertical menu: each full-width row has a leading icon and left-aligned wrapping label. Target choices use the same row treatment. On iOS 26 the panel uses a native rounded-rectangle glass backdrop, including at larger text sizes; Reduce Transparency and older iOS keep an opaque raised surface. This panel opt-in does not change the existing multiline drawer-title fallback. Android retains its opaque OCAT surface. The panel still uses the existing modal fade, not a native glass morph transition. + - Android uses the Material 3 flexible navigation bar instead of the island: docked full width at the bottom, edge to edge under the system navigation bar, 64dp tall (`materialNavigationBar`), on the `raised` surface with level-2 elevation and no corners. Each destination is a 56×32dp full-pill indicator in `raised.chosen` when selected, a 24dp icon, 4dp gap and a 12sp label (strong when selected), with a borderless ripple. Expo UI 58's Compose `NavigationBar` only wraps the baseline 80dp bar, which Material 3 marks not recommended, so this is drawn to the m3.material.io token values. The bar grows with system text and hides on scroll like the island. The Activity destination shows the bell **or** the capped pending count, never both, on iOS and Android alike: when Activity is not selected and something is pending, the count replaces the bell; when nothing is pending, or Activity is selected (its feed already shows the items), the bell returns. Android cross-fades the swap. The accessible label always carries the count. Essential labels grow with system text. + - **+** opens a modal action panel in the island position, with close in the circular position. Back, accessibility escape and backdrop only dismiss it. The panel is always a scrollable vertical menu: each full-width row has a leading icon and left-aligned wrapping label. Target choices use the same row treatment. On iOS 26 the panel uses a native rounded-rectangle glass backdrop, including at larger text sizes; Reduce Transparency and older iOS keep an opaque raised surface. This panel opt-in does not change the existing multiline drawer-title fallback. The panel still uses the existing modal fade, not a native glass morph transition. + - On Android, creation is a Material 3 **FAB** on Chats only: 56dp, 16dp corners, `canvas.chosen` (primary container), level-3 elevation, 16dp from the trailing edge and 16dp above the navigation bar. It opens a **FAB menu** (m3.material.io FAB menu tokens, `fab`): the FAB becomes a 56dp circular close button in the accent colour, and the actions stack above it, trailing-aligned, as full-pill items 56dp tall, 4dp apart, hugging their icon and label with 24dp side padding. Target choices and Back use the same items under a notice; errors show in that notice. The items scroll behind the close button when space runs out. Back, accessibility escape and the scrim dismiss it. Expo UI 58 has no Compose FAB menu, so it is drawn to the token values. - Computer: new Channel/new Bot. Owner Organization: new Channel/invite. All: new Channel/new Bot/join with link or QR, followed by explicit destination choice where needed. Member/guest workspaces do not gain owner controls. Pairing and Organization administration stay in the drawer. - Creation binds the chosen Host ID or cloud account scope plus Organization ID. Cloud rechecks current identity and ownership before opening/submitting; stale scope refuses. Existing Host full-client and CloudClient idempotent mutations remain the business owners. Dismissal never starts a background operation. diff --git a/apps/mobile/src/ui/design/tokens.ts b/apps/mobile/src/ui/design/tokens.ts index f9d9b836bb..9cc7e6a2e7 100644 --- a/apps/mobile/src/ui/design/tokens.ts +++ b/apps/mobile/src/ui/design/tokens.ts @@ -42,6 +42,24 @@ export const navigationBar = { dark: '0 2px 8px rgba(0, 0, 0, 0.12)', }, } as const; +export const materialNavigationBar = { + height: 64, + indicatorWidth: 56, + indicatorHeight: 32, + iconLabelGap: 4, + elevation: 3, +} as const; +export const fab = { + size: 56, + radius: 16, + margin: 16, + elevation: 6, + menuItemHeight: 56, + menuItemGap: 4, + menuItemPadding: 24, + menuIconGap: 8, + menuCloseGap: 8, +} as const; export const materialDrawer = { width: 360, scrimGutter: 56, addressMinScale: 0.6 } as const; export const loadingIndicator = { size: 48 } as const; export const swipeAction = { size: 56 } as const; diff --git a/apps/mobile/src/ui/shell/bottom-navigation.tsx b/apps/mobile/src/ui/shell/bottom-navigation.tsx index 3a5efbc2b2..c1a7117c73 100644 --- a/apps/mobile/src/ui/shell/bottom-navigation.tsx +++ b/apps/mobile/src/ui/shell/bottom-navigation.tsx @@ -1,7 +1,14 @@ import { BottomTabBar, Tabs } from 'expo-router/js-tabs'; import { router } from 'expo-router'; import { useRef, useState, type ComponentProps } from 'react'; -import { Keyboard, View, useWindowDimensions, type ViewInstance } from 'react-native'; +import { + Keyboard, + Platform, + Pressable, + View, + useWindowDimensions, + type ViewInstance, +} from 'react-native'; import Animated, { useAnimatedStyle } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { hostSession, useHostSession } from '../../host/host-session'; @@ -20,12 +27,13 @@ import { cloudError } from '../../cloud/client'; import { ClientError } from '../../cloud/instance-contract'; import { prepareCloudCreation } from '../../cloud/prepare-creation'; import { shownHostName } from '../design/host-look'; -import { useDesignTheme } from '../design/theme'; -import { navigationBar, radius, space } from '../design/tokens'; +import { pressTint, useDesignTheme } from '../design/theme'; +import { fab, materialNavigationBar, navigationBar, radius, space } from '../design/tokens'; import { useMounted } from '../mounted'; import { NavigationMaterial } from './navigation-material'; import { NavigationDestinations } from './navigation-destinations'; import { NavigationAction } from './navigation-action'; +import { PlusIcon } from './icons'; import { CreationPanel } from './creation-panel'; import { FloatingTabInset } from './floating-tab-inset'; import { ScrollChromeProvider, useChromeHidden, useChromeProgress } from './scroll-chrome'; @@ -45,11 +53,22 @@ function NavigationLayout() { const theme = useDesignTheme(); const insets = useSafeAreaInsets(); const { fontScale } = useWindowDimensions(); - const size = Math.max( - navigationBar.height, - theme.type.title.line + theme.type.meta.line * fontScale * (fontScale > 1.3 ? 2 : 1) + space.x5, - ); - const bottom = Math.max(insets.bottom, space.x4); + const android = Platform.OS === 'android'; + const size = android + ? Math.max( + materialNavigationBar.height, + materialNavigationBar.indicatorHeight + + materialNavigationBar.iconLabelGap + + theme.type.meta.line * fontScale + + space.x4, + ) + : Math.max( + navigationBar.height, + theme.type.title.line + + theme.type.meta.line * fontScale * (fontScale > 1.3 ? 2 : 1) + + space.x5, + ); + const bottom = android ? insets.bottom : Math.max(insets.bottom, space.x4); const reserved = size + bottom + space.x5; const quickActions = useQuickActionRouting(); return ( @@ -93,6 +112,7 @@ function ScopedNavigationBar({ saved ? { hostId: saved.hostId, name: shownHostName(look, saved.hostName) } : undefined, directory.workspaces, ); + const theme = useDesignTheme(); const pending = useActivityCount().count ?? 0; const insets = useSafeAreaInsets(); const [menu, setMenu] = useState(); @@ -156,6 +176,100 @@ function ScopedNavigationBar({ setBusy(false); } }; + const items = state.routes.map((route, index) => ({ + key: route.key, + focused: state.index === index, + activity: route.name === '(activity)', + count: route.name === '(activity)' ? pending : 0, + onPress: () => { + const event = emitter.emit({ + type: 'tabPress', + target: route.key, + canPreventDefault: true, + }); + if (state.index !== index && !event.defaultPrevented) navigateToTab(route.key); + }, + onLongPress: () => emitter.emit({ type: 'tabLongPress', target: route.key }), + })); + const create = () => { + Keyboard.dismiss(); + setError(undefined); + setMenu(scope); + }; + const fabBottom = size + bottom + fab.margin; + const panel = menu === scope && ( + void choose(action, target)} + busy={busy} + error={error} + /> + ); + if (Platform.OS === 'android') + return ( + <> + + + + {state.routes[state.index]?.name === '(home)' ? ( + + + + + + ) : null} + {panel} + + ); return ( <> - ({ - key: route.key, - focused: state.index === index, - activity: route.name === '(activity)', - count: route.name === '(activity)' ? pending : 0, - onPress: () => { - const event = emitter.emit({ - type: 'tabPress', - target: route.key, - canPreventDefault: true, - }); - if (state.index !== index && !event.defaultPrevented) navigateToTab(route.key); - }, - onLongPress: () => emitter.emit({ type: 'tabLongPress', target: route.key }), - }))} - /> + - { - Keyboard.dismiss(); - setError(undefined); - setMenu(scope); - }} - /> + - {menu === scope && ( - void choose(action, target)} - busy={busy} - error={error} - /> - )} + {panel} ); } diff --git a/apps/mobile/src/ui/shell/creation-menu.ts b/apps/mobile/src/ui/shell/creation-menu.ts new file mode 100644 index 0000000000..7f39a300fe --- /dev/null +++ b/apps/mobile/src/ui/shell/creation-menu.ts @@ -0,0 +1,45 @@ +import { useState } from 'react'; +import { + creationActions, + targetsFor, + type CreationAction, + type CreationTarget, +} from '../../host/creation-targets'; +import { cloudAvailable } from '../../cloud/available'; +import { t } from '../strings'; + +export const creationLabel = (action: CreationAction) => + ({ + channel: t.newChannel, + bot: t.newPersonaBot, + invite: t.invitePeople, + join: t.cloudJoin.title, + })[action]; + +export type CreationPanelProps = { + scope: string; + targets: CreationTarget[]; + bottom: number; + size: number; + onClose(): void; + onPick(action: CreationAction, target?: CreationTarget): void; + busy: boolean; + error?: string | undefined; +}; + +export function useCreationMenu({ + scope, + targets, + onPick, +}: Pick) { + const [choosing, setChoosing] = useState(); + const actions = creationActions(scope, targets).filter((a) => cloudAvailable || a !== 'join'); + const choices = choosing ? targetsFor(choosing, scope, targets) : []; + const pick = (action: CreationAction) => { + if (action === 'join') return onPick(action); + if (scope === 'all') return setChoosing(action); + const target = targetsFor(action, scope, targets)[0]; + if (target) onPick(action, target); + }; + return { choosing, setChoosing, actions, choices, pick }; +} diff --git a/apps/mobile/src/ui/shell/creation-panel.android.tsx b/apps/mobile/src/ui/shell/creation-panel.android.tsx new file mode 100644 index 0000000000..ab75c3a2af --- /dev/null +++ b/apps/mobile/src/ui/shell/creation-panel.android.tsx @@ -0,0 +1,194 @@ +import { Modal, Pressable, ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import type { ReactNode } from 'react'; +import { pressTint, useDesignTheme } from '../design/theme'; +import { fab, radius, space } from '../design/tokens'; +import { Text } from '../primitives/text'; +import { + ChatsIcon, + ChevronLeftIcon, + CloseIcon, + CloudIcon, + ComputerIcon, + PersonIcon, + ShareIcon, +} from './icons'; +import { t } from '../strings'; +import { creationLabel, useCreationMenu, type CreationPanelProps } from './creation-menu'; + +export function CreationPanel({ + scope, + targets, + bottom, + onClose, + onPick, + busy, + error, +}: CreationPanelProps) { + const theme = useDesignTheme(); + const insets = useSafeAreaInsets(); + const { height } = useWindowDimensions(); + const { choosing, setChoosing, actions, choices, pick } = useCreationMenu({ + scope, + targets, + onPick, + }); + const ink = theme.color.fg; + const icon = theme.type.title.line; + const notice = choosing + ? choices.length === 0 + ? t.noCreationTargets + : t.chooseCreationTarget + : actions.length === 0 + ? t.noCreationTargets + : undefined; + return ( + + + + + + {notice === undefined && !error ? null : ( + + {notice === undefined ? null : {notice}} + {error ? {error} : null} + + )} + {choosing ? ( + <> + {choices.map((target) => { + const Icon = target.kind === 'computer' ? ComputerIcon : CloudIcon; + return ( + } + onPress={() => onPick(choosing, target)} + /> + ); + })} + } + onPress={() => setChoosing(undefined)} + /> + + ) : ( + actions.map((action) => { + const Icon = + action === 'channel' ? ChatsIcon : action === 'bot' ? PersonIcon : ShareIcon; + return ( + } + onPress={() => pick(action)} + /> + ); + }) + )} + + + + + + + + ); +} + +function FabMenuItem({ + label, + icon, + disabled, + onPress, +}: { + label: string; + icon: ReactNode; + disabled: boolean; + onPress(): void; +}) { + const theme = useDesignTheme(); + return ( + + + {icon} + + + {label} + + + ); +} diff --git a/apps/mobile/src/ui/shell/creation-panel.tsx b/apps/mobile/src/ui/shell/creation-panel.tsx index 65b3837a6c..24cd659f76 100644 --- a/apps/mobile/src/ui/shell/creation-panel.tsx +++ b/apps/mobile/src/ui/shell/creation-panel.tsx @@ -9,28 +9,14 @@ import { useWindowDimensions, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useState, type ReactNode } from 'react'; -import { - creationActions, - targetsFor, - type CreationAction, - type CreationTarget, -} from '../../host/creation-targets'; +import type { ReactNode } from 'react'; import { useDesignTheme } from '../design/theme'; import { radius, space } from '../design/tokens'; import { NavigationMaterial } from './navigation-material'; import { Text } from '../primitives/text'; import { ComputerIcon, CloudIcon, PersonIcon, ChatsIcon, ShareIcon } from './icons'; import { t } from '../strings'; -import { cloudAvailable } from '../../cloud/available'; - -export const creationLabel = (action: CreationAction) => - ({ - channel: t.newChannel, - bot: t.newPersonaBot, - invite: t.invitePeople, - join: t.cloudJoin.title, - })[action]; +import { creationLabel, useCreationMenu, type CreationPanelProps } from './creation-menu'; export function CreationPanel({ scope, @@ -41,28 +27,15 @@ export function CreationPanel({ onPick, busy, error, -}: { - scope: string; - targets: CreationTarget[]; - bottom: number; - size: number; - onClose(): void; - onPick(action: CreationAction, target?: CreationTarget): void; - busy: boolean; - error?: string | undefined; -}) { +}: CreationPanelProps) { const theme = useDesignTheme(); const insets = useSafeAreaInsets(); const { height } = useWindowDimensions(); - const [choosing, setChoosing] = useState(); - const actions = creationActions(scope, targets).filter((a) => cloudAvailable || a !== 'join'); - const choices = choosing ? targetsFor(choosing, scope, targets) : []; - const pick = (action: CreationAction) => { - if (action === 'join') return onPick(action); - if (scope === 'all') return setChoosing(action); - const target = targetsFor(action, scope, targets)[0]; - if (target) onPick(action, target); - }; + const { choosing, setChoosing, actions, choices, pick } = useCreationMenu({ + scope, + targets, + onPick, + }); return ( diff --git a/apps/mobile/src/ui/shell/navigation-destinations.tsx b/apps/mobile/src/ui/shell/navigation-destinations.tsx index 3d747614b0..7538b47891 100644 --- a/apps/mobile/src/ui/shell/navigation-destinations.tsx +++ b/apps/mobile/src/ui/shell/navigation-destinations.tsx @@ -1,6 +1,6 @@ import { Platform, Pressable, View } from 'react-native'; -import { useDesignTheme } from '../design/theme'; -import { navigationBar, radius } from '../design/tokens'; +import { pressTint, useDesignTheme } from '../design/theme'; +import { materialNavigationBar, navigationBar, radius } from '../design/tokens'; import { NavigationIcon } from './navigation-icon'; import { Text } from '../primitives/text'; import { badgeCount } from '../primitives/badge-count'; @@ -9,6 +9,7 @@ import { t } from '../strings'; import type { NavigationDestination } from './navigation-destination'; export function NavigationDestinations({ items }: { items: NavigationDestination[] }) { const theme = useDesignTheme(); + const android = Platform.OS === 'android'; return ( <> {items.map((item) => { @@ -28,22 +29,28 @@ export function NavigationDestinations({ items }: { items: NavigationDestination accessibilityState={{ selected: focused }} onPress={item.onPress} onLongPress={item.onLongPress} + android_ripple={{ + color: pressTint(theme.color.fg), + borderless: true, + radius: materialNavigationBar.indicatorWidth / 2, + }} style={({ pressed }) => ({ flex: 1, alignItems: 'center', justifyContent: 'center', minHeight: theme.touch, + gap: android ? materialNavigationBar.iconLabelGap : 0, borderRadius: radius.round, backgroundColor: - pressed || (focused && Platform.OS !== 'android') - ? theme.layer.raised.chosen - : 'transparent', + !android && (pressed || focused) ? theme.layer.raised.chosen : 'transparent', })} > - + {activity ? t.activity : t.chats} diff --git a/apps/mobile/src/ui/shell/navigation-icon.android.tsx b/apps/mobile/src/ui/shell/navigation-icon.android.tsx index 7ca929adae..210f41e01c 100644 --- a/apps/mobile/src/ui/shell/navigation-icon.android.tsx +++ b/apps/mobile/src/ui/shell/navigation-icon.android.tsx @@ -4,7 +4,7 @@ import { View } from 'react-native'; import Animated, { useAnimatedStyle, useDerivedValue, withTiming } from 'react-native-reanimated'; import { useDesignTheme } from '../design/theme'; -import { motion, navigationBar, tabMotion } from '../design/tokens'; +import { materialNavigationBar, motion, radius, tabMotion } from '../design/tokens'; import { badgeCount } from '../primitives/badge-count'; import { Text } from '../primitives/text'; @@ -49,9 +49,9 @@ export function NavigationIcon({ import('./react-native-shim')); +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})); +vi.mock('react-native-svg', () => ({ default: () => null, Path: () => null, Rect: () => null })); +vi.mock('../src/ui/primitives/drawer-material', () => ({ DrawerMaterial: () => null })); +import { CreationPanel } from '../src/ui/shell/creation-panel.android'; +import { creationTargets } from '../src/host/creation-targets'; +import { computerEnvironmentKey } from '../src/host/conversation-environments'; +import { t } from '../src/ui/strings'; +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +let root: Root; +let host: HTMLElement; +const pick = vi.fn(), + close = vi.fn(); +afterEach(() => { + act(() => root?.unmount()); + host?.remove(); + pick.mockReset(); + close.mockReset(); +}); +async function render(scope = 'all') { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + await act(async () => + root.render( + , + ), + ); +} +async function click(label: string) { + const button = [...host.querySelectorAll('button')].find( + (b) => b.getAttribute('aria-label') === label, + ); + expect(button, label).toBeDefined(); + await act(async () => button!.click()); +} +it('Android FAB menu asks All to choose a target even when only one Computer is available', async () => { + await render(); + await click(t.newChannel); + expect(pick).not.toHaveBeenCalled(); + await click('Mac'); + expect(pick).toHaveBeenCalledWith('channel', expect.objectContaining({ hostId: 'mac' })); +}); +it('Android FAB menu offers only Computer actions in its selected scope', async () => { + await render(computerEnvironmentKey('mac')); + expect(host.textContent).not.toContain(t.cloudJoin.title); + await click(t.newPersonaBot); + expect(pick).toHaveBeenCalledWith('bot', expect.objectContaining({ hostId: 'mac' })); +}); +it('Android FAB menu dismisses without selecting a background destination', async () => { + await render(); + await click(t.close); + expect(close).toHaveBeenCalledOnce(); + expect(pick).not.toHaveBeenCalled(); +}); +it('Android FAB menu joins directly without inventing a Computer target', async () => { + await render(); + await click(t.cloudJoin.title); + expect(pick).toHaveBeenCalledWith('join'); +}); +it('Android FAB menu goes back from the target step and closes from the FAB position', async () => { + await render(); + await click(t.newChannel); + expect(host.textContent).toContain(t.chooseCreationTarget); + await click(t.back); + expect(host.textContent).toContain(t.newPersonaBot); + expect(host.textContent).not.toContain(t.chooseCreationTarget); +}); diff --git a/changes/2350-android-m3-navigation.md b/changes/2350-android-m3-navigation.md new file mode 100644 index 0000000000..204d9260ac --- /dev/null +++ b/changes/2350-android-m3-navigation.md @@ -0,0 +1,11 @@ +--- +section: Changed +--- + +## en + +- On Android, the mobile app uses the Material 3 navigation bar, and Chats creates channels and Bots from a floating action button with a FAB menu ([#2350](https://github.com/BotHarness/DeepSeekBot/pull/2350)). + +## zh + +- Android 手机端改用 Material 3 底部导航栏;聊天页右下角的悬浮按钮(FAB)展开菜单,用来新建频道和 Bot([#2350](https://github.com/BotHarness/DeepSeekBot/pull/2350))。 diff --git a/docs/adr/0235-mobile-creation-shares-the-existing-tab-navigator.md b/docs/adr/0235-mobile-creation-shares-the-existing-tab-navigator.md index 165aa4d020..6f05461e91 100644 --- a/docs/adr/0235-mobile-creation-shares-the-existing-tab-navigator.md +++ b/docs/adr/0235-mobile-creation-shares-the-existing-tab-navigator.md @@ -17,3 +17,7 @@ Creation resolves stable source keys, never display names. All requires an expli ## Consequences The iOS tab bar is now app-owned presentation. Native glass support does not prove layout, gestures or accessibility: exact-head shared-queue Simulator checks must qualify adjacent geometry, dismissal, safe area, large text and actual creation. Android, physical-device and screen-reader gates remain independent. The expanded menu uses a native rounded-rectangle glass backdrop on iOS 26, including with large text; Reduce Transparency, older iOS and Android retain opaque surfaces. This is the existing modal fade, not an asserted native glass morph animation. N3 adds Search only with functioning search behavior. + +## Amendment: Android Material 3 (2026-10-19) + +Android now follows Material 3 instead of sharing the island: the same JS tab navigator renders a docked flexible navigation bar (64dp, pill indicator) and creation moves to a FAB on Chats that opens a FAB menu. Routes, creation targets, preflight and dismissal rules above are unchanged. Expo UI 58 wraps only the baseline 80dp Compose `NavigationBar` and has no FAB menu, so both are React Native views drawn to the m3.material.io token values; revisit when Expo UI exposes `ShortNavigationBar` and `FloatingActionButtonMenu`.