Files
buzz/mobile/lib/shared/widgets/buzz_setting_menu.dart
klopez4212 cb69ad9e4f Adapt iPhone Duo layouts, rail navigation, and native sheets (#8568)
Duo keeps conversations and Settings details open across closed, book
and flat postures. Book mode splits at the fold; flattening restores the
sidebar. Native rail controls, full-width navigation backdrops and
sheet-local safe areas keep content aligned. Ordinary iPhones remain
single-pane in portrait and landscape; tablet and Duo split layouts
remain enabled.

Includes the recovered preview's matching pane widths, Buzz empty-pane
mark, stable Settings chevrons, Bestie pane routing and Duo-only upward
New menu attachment. Current main's Activity redesign, Search content
fade and unread-navigation button are retained. Menu selection checks
sit beside their labels.

Shared mobile changes remain in scope: the native iOS status editor,
Appearance preview layout, avatar crop preservation, onboarding footer
layout, profile/invite sheet geometry and nested conversation
navigation. This PR is not exclusively Duo-gated. Nested navigator
observers preserve recording cancellation and navigation callbacks; crop
resizing preserves the selected image region. iOS 27.1 APIs remain
compile-time and runtime gated.

Validation on the reconciled source: Flutter analysis, Activity/Home
journeys, the full mobile test suite, file-size checks and Swift package
tests. The iPhone single-pane fix was also exercised on an iPhone 18 Pro
simulator before reconciliation. Fresh hosted CI and physical-device
confirmation of the reconciled build are separate and remain pending.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-11-12 19:00:58 +00:00

263 lines
9.4 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import '../theme/buzz_icons.dart';
import '../theme/theme.dart';
import 'anchored_popover_menu.dart';
import 'app_list.dart';
import 'ios_message_create_button.dart';
/// One choice in a [BuzzSettingMenu].
class BuzzSettingMenuOption<T> {
final Key? key;
final String label;
final T value;
const BuzzSettingMenuOption({
this.key,
required this.label,
required this.value,
});
}
/// A bordered settings row showing its current value, opening a checked menu.
class BuzzSettingMenu<T> extends StatelessWidget {
final Key controlKey;
final bool enabled;
final String label;
final ValueChanged<T> onSelected;
final List<BuzzSettingMenuOption<T>> options;
final T? value;
final IconData? valueIcon;
final String valueLabel;
/// Use the same borderless row as Settings inside a shared card.
final bool grouped;
/// Keep an active form field and its keyboard focused while choosing a value.
final bool preserveTextInput;
/// Affordance for opening the menu.
final IconData trailingIcon;
const BuzzSettingMenu({
super.key,
required this.controlKey,
required this.enabled,
required this.label,
required this.onSelected,
required this.options,
required this.value,
this.valueIcon,
required this.valueLabel,
this.grouped = false,
this.preserveTextInput = false,
this.trailingIcon = BuzzIcons.chevronDown,
});
@override
Widget build(BuildContext context) {
final menu = _buildMenu(context);
return preserveTextInput ? TextFieldTapRegion(child: menu) : menu;
}
Widget _buildMenu(BuildContext context) {
if (grouped && defaultTargetPlatform == TargetPlatform.iOS) {
return Stack(
children: [
ExcludeSemantics(
child: Opacity(
opacity: enabled ? 1 : 0.5,
child: AppListRow(
title: label,
value: valueLabel,
trailing: Icon(
trailingIcon,
size: 16,
color: context.colors.onSurfaceVariant,
),
),
),
),
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) => IosMessageCreateButton(
key: controlKey,
label: '',
fillWidth: true,
accessibilityLabel: '$label: $valueLabel',
width: constraints.maxWidth,
height: constraints.maxHeight,
enabled: enabled,
eagerGestures: false,
deferMenuActions: false,
selectedIndex: options.indexWhere(
(option) => option.value == value,
),
actions: [
for (final option in options)
(
label: option.label,
symbol: '',
onPressed: enabled
? () => onSelected(option.value)
: null,
),
],
),
),
),
],
);
}
void toggleMenu(MenuController menu) {
if (menu.isOpen) return menu.close();
if (!preserveTextInput) FocusManager.instance.primaryFocus?.unfocus();
menu.open();
}
final shape = RoundedRectangleBorder(
side: BorderSide(color: context.colors.outlineVariant),
borderRadius: BorderRadius.circular(Radii.container),
);
// The menu spans the row, like Material's dropdown menus.
return LayoutBuilder(
builder: (context, constraints) => MenuAnchor(
alignmentOffset: const Offset(0, Grid.half),
// Match the app's shared popover surface.
style: MenuStyle(
shape: WidgetStatePropertyAll(appPopoverShape(context)),
backgroundColor: WidgetStatePropertyAll(appPopoverColor(context)),
shadowColor: WidgetStatePropertyAll(appPopoverShadowColor(context)),
elevation: const WidgetStatePropertyAll(appPopoverElevation),
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(vertical: Grid.half),
),
),
menuChildren: [
for (final option in options)
// The check icon carries no semantics, so mark the selection on
// the item's own node.
TextFieldTapRegion(
enabled: preserveTextInput,
child: MergeSemantics(
child: Semantics(
checked: option.value == value,
inMutuallyExclusiveGroup: true,
child: MenuItemButton(
key: option.key,
onPressed: () => onSelected(option.value),
style: MenuItemButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
),
),
// Keep selection beside the label, matching other checked menus.
leadingIcon: option.value == value
? Icon(
Icons.check,
size: 20,
color: context.colors.primary,
)
: const SizedBox(width: 20),
// Root menu panels ignore size styles and shrink-wrap their items,
// so the label sets the width: the row's, less the item's padding,
// the check and the gap before it.
child: SizedBox(
width: constraints.maxWidth - Grid.twelve * 3 - 20,
child: Text(option.label),
),
),
),
),
),
],
builder: (context, menu, _) => Semantics(
button: true,
enabled: enabled,
expanded: menu.isOpen,
child: Tooltip(
message: '$label: $valueLabel',
// Draw on an ink surface so the tap ripple shows inside the border.
child: grouped
? Opacity(
opacity: enabled ? 1 : 0.5,
child: AppListRow(
key: controlKey,
title: label,
value: valueLabel,
trailing: Icon(
trailingIcon,
size: 16,
color: context.colors.onSurfaceVariant,
),
onTap: enabled ? () => toggleMenu(menu) : null,
),
)
: Material(
color: context.colors.surface,
shape: shape,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: enabled ? () => toggleMenu(menu) : null,
child: Padding(
key: controlKey,
padding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xs,
),
child: Opacity(
opacity: enabled ? 1 : 0.5,
child: Row(
children: [
Expanded(
child: Text(
label,
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
if (valueIcon case final icon?) ...[
Icon(
icon,
size: 16,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.half),
],
// Long values truncate instead of squeezing the label.
ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 180,
),
child: Text(
valueLabel,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: Grid.half),
Icon(
trailingIcon,
size: 16,
color: context.colors.onSurfaceVariant,
),
],
),
),
),
),
),
),
),
),
);
}
}