diff --git a/.github/pr-screenshots/2348-android-shape/phone/filter-sheet-dark.png b/.github/pr-screenshots/2348-android-shape/phone/filter-sheet-dark.png new file mode 100644 index 0000000000..6499b95068 Binary files /dev/null and b/.github/pr-screenshots/2348-android-shape/phone/filter-sheet-dark.png differ diff --git a/.github/pr-screenshots/2348-android-shape/phone/filter-sheet-font.png b/.github/pr-screenshots/2348-android-shape/phone/filter-sheet-font.png new file mode 100644 index 0000000000..c630e79070 Binary files /dev/null and b/.github/pr-screenshots/2348-android-shape/phone/filter-sheet-font.png differ diff --git a/.github/pr-screenshots/2348-android-shape/phone/filter-sheet-light.png b/.github/pr-screenshots/2348-android-shape/phone/filter-sheet-light.png new file mode 100644 index 0000000000..e25f8928ae Binary files /dev/null and b/.github/pr-screenshots/2348-android-shape/phone/filter-sheet-light.png differ diff --git a/.github/pr-screenshots/2348-android-shape/phone/settings-dark.png b/.github/pr-screenshots/2348-android-shape/phone/settings-dark.png new file mode 100644 index 0000000000..7ecd71b7cc Binary files /dev/null and b/.github/pr-screenshots/2348-android-shape/phone/settings-dark.png differ diff --git a/.github/pr-screenshots/2348-android-shape/phone/settings-font.png b/.github/pr-screenshots/2348-android-shape/phone/settings-font.png new file mode 100644 index 0000000000..88497154df Binary files /dev/null and b/.github/pr-screenshots/2348-android-shape/phone/settings-font.png differ diff --git a/.github/pr-screenshots/2348-android-shape/phone/settings-light.png b/.github/pr-screenshots/2348-android-shape/phone/settings-light.png new file mode 100644 index 0000000000..01241fc08f Binary files /dev/null and b/.github/pr-screenshots/2348-android-shape/phone/settings-light.png differ diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index 3843ed521d..64134cce06 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -125,7 +125,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - A floating header follows the chat chrome: title on the left, sorting, filtering and Activity search on the right. Filters live in a sheet, with category, Bot, Channel, unread-message and clear controls; no permanent filter chips. A nonblank Activity search hides sections with no matches; an entirely empty result shows one no-results message. Clearing the query restores normal empty sections. Sections with another page remain reachable. Search uses safe Activity summaries and Channel/Bot labels on the current Host before pagination. Handled history stays excluded unless selected. Default ordering is newest action first, mentions before other personal messages, then newest unread conversation; explicit newest/oldest overrides personal-message priority. Each section can load another bounded page. Hosts without query version 1 show an update error. Complete tab totals remain a subsequent slice of [#1954](https://github.com/BotHarness/DeepSeekBot/issues/1954); when another page exists, the screen identifies counts as loaded items. - **Settings** opens from the gear as an immediate-settings sheet: one handle, fixed header and scrolling body; swipe or activation of the handle dismisses while completed settings remain. Child pages use Back within the same sheet. Android retains its fixed accessible handle, large detent and 28dp top corners. On iOS, native modal presentation keeps nested content visible; the shared frame supplies one handle in the native vertical dismissal region, and the native header owns only the root title. The handle is also a Close button with a platform-sized touch target, explicit accessibility activation and escape; no extra X is shown. Child Back still pops only one page. Actual screen-reader focus and gestures require separate native acceptance. There is no Done/save step for immediate preferences. Settings reuses row and section components, in groups. Each navigable or action row leads with a system icon (`RowIcon`: an SF Symbol on iOS, a Material Symbol on Android), monochrome `fg`, or `accent`/`danger` to match the row's tone, so a row's purpose reads before its label: - **Models and presets** configures this Host's existing global default model and shared Model Preset library. Bots without an override follow that default. Presets can be created, renamed and edited for Orchestrator, Assignment default, allowed Assignment models and reasoning effort, through the existing Host methods. Editing a preset does not rewrite Bots that already applied a copy. Model selection pushes inside the same drawer, searches model/provider names and IDs, virtualizes the catalog, and keeps missing-key models visible but unavailable for selection. Saved settings and read errors are scoped to the Host and Bot/preset; cached content remains visible during refresh or failure, with retry. - - Android groups use `androidSettings` tokens: 28dp corners, 16dp row padding, 56dp minimum single-line rows and 72dp minimum supporting rows that expand with system text. Section headings use the accent role, supporting text uses the body ramp and muted role, and separators are inset 16dp. Selected choices use the layer's chosen fill plus accent text and the existing checkmark. Explicit navigation rows show a chevron; destructive and copy actions do not imply navigation. Android switches use SDK 58 Expo UI's native Material 3 control inside a bounded Host; state and persistence remain owned by existing preferences. iOS retains its existing rows and switch adapter. + - Android groups use `androidSettings` tokens and the Material 3 segmented list: each row is its own `raised` tile, 2dp apart, with 16dp outer corners on the group's first and last rows and 4dp inner corners between rows (`segmentCorners`), and no divider lines. Rows have 16dp padding, 56dp minimum single-line height and 72dp minimum supporting height, and expand with system text. Section headings use the accent role and supporting text uses the body ramp and muted role. Selected choices use the layer's chosen fill plus accent text and the existing checkmark. Explicit navigation rows show a chevron; destructive and copy actions do not imply navigation. Android switches use SDK 58 Expo UI's native Material 3 control inside a bounded Host; state and persistence remain owned by existing preferences. iOS retains its existing rows and switch adapter. - **Usage** comes first, as one card for the current Host, like a fitness app's week: "Usage", "Last 7 days" and a chevron, the 7-day token total in `display`, and a bar per day (`DayBars`). An idle Host shows a quiet week of stubs, and a failed read shows a danger line with Try again under the card. Tapping it pushes the **Usage** screen: - A Today / 7 days / 4 weeks / 12 weeks `Segmented` switch; switching reads the Host again. A background 4-week read checks support on entry; until it succeeds, and on older Hosts, only Today and 7 days appear. An `invalid-input` refusal of a longer period returns to 7 days without showing an error. The Settings card does not probe or change its 7-day range. - The period's dates, its total, daily bars for 7 and 28 days, and 12 weekly bars for 84 days. Each week starts at the range's first day, not a calendar-week boundary; a selected weekly bar shows its date range and partial-aware total. Longer charts omit cramped weekday labels, retaining their dates in the chart's accessibility label. A muted note explains when some usage could not be counted or is still being counted. @@ -293,7 +293,7 @@ Each layer has `fill`, `pressed`, `selected` (neutral, such as the current tab), - `chosen` is the accent over the fill, 13% in light and 30% in dark. On `well` it equals DSH `state-business-tertiary`, and it follows a Bot's accent. - A state is pushed further from its fill until it stands apart from the fill, the layer under it and the other states (`SEPARATION` in `layers.ts`; `test/design-tokens.test.ts` checks every scheme and accent). DSH's dark `interactive-bg-active` over `bg-base` equals `bg-module-platform`, so without this a selected row in a card looks like the card. - Buttons are solid pills (`radius.round`) with no border, and take their colours from `buttonColors(theme, tone, on)` (`controls.ts`) for the layer they sit `on`. Primary is the neutral `fg` fill with a `base` label, never the Bot accent. Secondary is the layer's `selected`, or the next layer's fill when `selected` is too close to the layer. Danger is the layer's `danger` tint with a red label. Each fill stands apart from the layer and from the other tones, and each label reaches 4.5:1. -- Bottom sheets (`chat/sheet.tsx`) follow the [overlay contract](./OVERLAYS.md), with one functional handle, task-specific header actions and `theme.overlay.fill`. The existing fallback Close/grabber implementation requires caller-by-caller migration. Their content is iOS-style inset groups on `raised` (`SettingsGroupItem`): rows separated by `divider` hairlines, a muted heading above each group and a muted footnote below it. A warning goes in a `warningSoft` callout, not orange text. A selected option shows a `fg` check, and switches use `Toggle` (`accent` track when on). +- Bottom sheets (`chat/sheet.tsx`) follow the [overlay contract](./OVERLAYS.md), with one functional handle, task-specific header actions and `theme.overlay.fill`. The existing fallback Close/grabber implementation requires caller-by-caller migration. Their content is grouped rows on `raised` (`SettingsGroupItem`): on iOS inset groups with `divider` hairlines, on Android the segmented list above, a muted heading above each group and a muted footnote below it. A warning goes in a `warningSoft` callout, not orange text. A selected option shows a `fg` check, and switches use `Toggle` (`accent` track when on). - Translucent roles (`hover`, `selected`, `divider`, `border*`, `dangerSoft`) are never a `backgroundColor`; `test/style-guard.test.ts` enforces this. Translucent edges stay allowed. ## Components diff --git a/apps/mobile/src/ui/design/segment-corners.ts b/apps/mobile/src/ui/design/segment-corners.ts new file mode 100644 index 0000000000..e7f31ac44f --- /dev/null +++ b/apps/mobile/src/ui/design/segment-corners.ts @@ -0,0 +1,12 @@ +import { androidSettings } from './tokens'; + +export function segmentCorners(first: boolean, last: boolean) { + const top = first ? androidSettings.listRadius : androidSettings.innerRadius; + const bottom = last ? androidSettings.listRadius : androidSettings.innerRadius; + return { + borderTopLeftRadius: top, + borderTopRightRadius: top, + borderBottomLeftRadius: bottom, + borderBottomRightRadius: bottom, + }; +} diff --git a/apps/mobile/src/ui/design/tokens.ts b/apps/mobile/src/ui/design/tokens.ts index c1e0d2e9a1..4a071a2fb4 100644 --- a/apps/mobile/src/ui/design/tokens.ts +++ b/apps/mobile/src/ui/design/tokens.ts @@ -50,6 +50,9 @@ export const pairingScan = { maxSize: 200, widthRatio: 0.5 } as const; export const androidSettings = { radius: radius.xl, + listRadius: radius.lg, + innerRadius: radius.xs, + segmentGap: 2, rowHeight: 56, supportingRowHeight: 72, inset: space.x5, diff --git a/apps/mobile/src/ui/primitives/segmented.android.tsx b/apps/mobile/src/ui/primitives/segmented.android.tsx index 2c302cb349..708a6791cf 100644 --- a/apps/mobile/src/ui/primitives/segmented.android.tsx +++ b/apps/mobile/src/ui/primitives/segmented.android.tsx @@ -8,8 +8,9 @@ import { fillMaxWidth } from '@expo/ui/jetpack-compose/modifiers'; import { Pressable, View, useWindowDimensions } from 'react-native'; import { pressTint, useDesignTheme } from '../design/theme'; -import { radius, space } from '../design/tokens'; +import { androidSettings, space } from '../design/tokens'; import { RadioMark } from './radio-mark'; +import { segmentCorners } from '../design/segment-corners'; import type { SegmentedProps } from './segmented'; import { Text } from './text'; @@ -46,11 +47,13 @@ export function Segmented({ ) ) return ( - + {options.map((option, index) => { const selected = option.value === value; - const top = index === 0 ? radius.lg : radius.xs; - const bottom = index === options.length - 1 ? radius.lg : radius.xs; return ( ({ paddingHorizontal: space.x5, paddingVertical: space.x3, overflow: 'hidden', - borderTopLeftRadius: top, - borderTopRightRadius: top, - borderBottomLeftRadius: bottom, - borderBottomRightRadius: bottom, + ...segmentCorners(index === 0, index === options.length - 1), backgroundColor: selected ? layer.chosen : layer.fill, }} > diff --git a/apps/mobile/src/ui/primitives/settings-row.tsx b/apps/mobile/src/ui/primitives/settings-row.tsx index 09ba346f1d..961f657d9b 100644 --- a/apps/mobile/src/ui/primitives/settings-row.tsx +++ b/apps/mobile/src/ui/primitives/settings-row.tsx @@ -8,6 +8,7 @@ import { androidSettings, edge, radius, space } from '../design/tokens'; import { pressTint, useDesignTheme } from '../design/theme'; import { ChevronRightIcon } from '../shell/icons'; import { Text } from './text'; +import { segmentCorners } from '../design/segment-corners'; function SettingsFrame({ onPress, @@ -147,22 +148,32 @@ export function SettingsSection({ return ( {title === undefined ? null : {title}} - - {android - ? rows.map((child, index) => ( - - {index === 0 ? null : } - {child} - - )) - : children} - + {android ? ( + + {rows.map((child, index) => ( + + {child} + + ))} + + ) : ( + + {children} + + )} {footer === undefined ? null : {footer}} ); @@ -187,19 +198,6 @@ export function SettingsHeading({ children }: { children: string }) { ); } -function SettingsDivider({ on }: { on: LayerName }) { - const theme = useDesignTheme(); - return ( - - ); -} - export function SettingsFooter({ children }: { children: string }) { return ( @@ -222,22 +220,34 @@ export function SettingsGroupItem({ const theme = useDesignTheme(); const layer = theme.layer[on]; const android = Platform.OS === 'android'; - const corners = android ? androidSettings.radius : radius.md; + if (android) + return ( + + {children} + + ); return ( - {android && !first ? : null} {children} ); diff --git a/apps/mobile/src/ui/shell/activity-filters.tsx b/apps/mobile/src/ui/shell/activity-filters.tsx index deb19a82d3..23d364b536 100644 --- a/apps/mobile/src/ui/shell/activity-filters.tsx +++ b/apps/mobile/src/ui/shell/activity-filters.tsx @@ -5,9 +5,10 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { INBOX_SECTIONS, type InboxSection, type InboxSetAside } from '../../host/inbox'; import { Sheet } from '../chat/sheet'; import { useDesignTheme } from '../design/theme'; -import { edge, radius, space } from '../design/tokens'; +import { androidSettings, edge, radius, space } from '../design/tokens'; import { DrawerHeader } from '../primitives/drawer-header'; import { RadioMark } from '../primitives/radio-mark'; +import { segmentCorners } from '../design/segment-corners'; import { Text } from '../primitives/text'; import { Toggle } from '../primitives/toggle'; import { CheckIcon } from './icons'; @@ -203,7 +204,7 @@ function OptionsEditor({ bots, channels, filter, sort, onFilter, onSort, onClose marginHorizontal: space.x5, paddingHorizontal: space.x5, paddingVertical: space.x3, - borderRadius: ios ? radius.md : 0, + borderRadius: ios ? radius.md : androidSettings.listRadius, backgroundColor: theme.layer.raised.fill, }} > @@ -233,12 +234,20 @@ function OptionsEditor({ bots, channels, filter, sort, onFilter, onSort, onClose marginHorizontal: space.x5, paddingHorizontal: space.x5, paddingVertical: space.x3, - borderTopLeftRadius: ios && first ? radius.md : 0, - borderTopRightRadius: ios && first ? radius.md : 0, - borderBottomLeftRadius: ios && last ? radius.md : 0, - borderBottomRightRadius: ios && last ? radius.md : 0, - borderBottomWidth: last ? 0 : edge.hairline, - borderBottomColor: theme.layer.raised.divider, + ...(ios + ? { + borderTopLeftRadius: first ? radius.md : 0, + borderTopRightRadius: first ? radius.md : 0, + borderBottomLeftRadius: last ? radius.md : 0, + borderBottomRightRadius: last ? radius.md : 0, + borderBottomWidth: last ? 0 : edge.hairline, + borderBottomColor: theme.layer.raised.divider, + } + : { + ...segmentCorners(first, last), + marginTop: first ? 0 : androidSettings.segmentGap, + overflow: 'hidden', + }), backgroundColor: pressed ? theme.layer.raised.pressed : theme.layer.raised.fill, })} > diff --git a/apps/mobile/test/material-controls-android.test.tsx b/apps/mobile/test/material-controls-android.test.tsx index ff619a7e66..f8e5f0e48b 100644 --- a/apps/mobile/test/material-controls-android.test.tsx +++ b/apps/mobile/test/material-controls-android.test.tsx @@ -62,6 +62,7 @@ vi.mock('@expo/ui/jetpack-compose', async () => { createElement('i', { 'data-testid': 'native-radio', 'data-selected': props.selected }), }; }); +vi.mock('../src/ui/shell/icons', () => ({ ChevronRightIcon: () => null })); vi.mock('../src/ui/primitives/radio-mark', () => import('../src/ui/primitives/radio-mark.android')); vi.mock('@expo/ui/jetpack-compose/modifiers', () => ({ fillMaxWidth: () => ({}) })); diff --git a/apps/mobile/test/segmented-list-android.test.ts b/apps/mobile/test/segmented-list-android.test.ts new file mode 100644 index 0000000000..a8aea7bcba --- /dev/null +++ b/apps/mobile/test/segmented-list-android.test.ts @@ -0,0 +1,19 @@ +import { expect, it } from 'vitest'; + +import { androidSettings } from '../src/ui/design/tokens'; +import { segmentCorners } from '../src/ui/design/segment-corners'; + +it('rounds the outer corners of a segmented list and keeps inner corners soft', () => { + expect(androidSettings.listRadius).toBe(16); + expect(androidSettings.innerRadius).toBe(4); + expect(androidSettings.segmentGap).toBe(2); + expect(segmentCorners(true, false)).toEqual({ + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + borderBottomLeftRadius: 4, + borderBottomRightRadius: 4, + }); + expect(segmentCorners(false, false).borderTopLeftRadius).toBe(4); + expect(segmentCorners(false, true).borderBottomRightRadius).toBe(16); + expect(new Set(Object.values(segmentCorners(true, true)))).toEqual(new Set([16])); +}); diff --git a/changes/2348-android-segmented-lists.md b/changes/2348-android-segmented-lists.md new file mode 100644 index 0000000000..4504e28d53 --- /dev/null +++ b/changes/2348-android-segmented-lists.md @@ -0,0 +1,11 @@ +--- +section: Changed +--- + +## en + +- On Android, grouped lists in Settings and sheets now use the Material 3 segmented list: soft 16dp outer corners, 4dp corners between rows and a 2dp gap instead of divider lines ([#2348](https://github.com/BotHarness/DeepSeekBot/pull/2348)). + +## zh + +- Android 上设置页和底部面板里的分组列表改用 Material 3 分段列表:外侧 16dp 柔和圆角,行与行之间 4dp 圆角加 2dp 间隙,不再用分隔线([#2348](https://github.com/BotHarness/DeepSeekBot/pull/2348))。