Files
buzz/mobile/ios/Runner/NativeMessageActionSurface.swift
klopez4212 c8ea4fb4eb feat(mobile): polish Home navigation and Solo workspace (#8393)
## Summary
Polish mobile Home and Solo: group Search/Inbox beside a separate
profile avatar, move Inbox and Settings to bottom-up pages with Close
controls, and place tabs opposite the plus/compose action. Morph plus to
compose clockwise when entering Solo and reverse counterclockwise when
returning Home, with a directional quarter-turn of the icon inside a
level button and a subtle scale pulse, respecting reduced motion. Align
Search’s Close control with Inbox, simplify its placeholder, and
initialize selected filter contrast correctly. Collapse the plus menu
when leaving Home. Center Solo’s native-glass New chat empty state and
keep it stationary behind the keyboard.

Standardize Tabler icons and neutral backgrounds, retain SF Back/Close
on iOS, use native Liquid Glass headers, tab container, and actions
(solid grey selected tab, slightly darker on iOS) on iOS and black
actions on Android, and smooth the expanded menu corners. Repair native
header safe-area/return layout and compose movement after cancellation.

### Related issue
None found for this UI refinement; follows #8337.

### Testing
- Formatting, analysis, full mobile suite: 3,487 passed, 4 skipped; 3
push-configuration tests passed. File-size checks passed.
- Base UI built and installed on Pixel and iPhone in place. Pixel
navigation/New chat cancellation checked. Subsequent glass and rotation
refinements are previewed on iPhone; motion feel awaits human
confirmation.
- Draft pending human confirmation of the final Settings entrance/return
behavior on iPhone.

### Pixel snapshots
| Solo | Expanded actions |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-solo.png"
width="280" alt="Solo empty state with New chat" /> | <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-actions.png"
width="280" alt="Expanded channel actions with smooth corners" /> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-16 12:28:02 -04:00

456 lines
14 KiB
Swift

import Flutter
import UIKit
struct NativeMessageActionDefinition {
enum Group: String, CaseIterable {
case primary
case utility
case destructive
}
let id: String
let title: String
let symbol: String
let group: Group
let isDestructive: Bool
init?(arguments: [String: Any]) {
guard
let id = arguments["id"] as? String,
let title = arguments["title"] as? String,
let symbol = arguments["symbol"] as? String,
let groupName = arguments["group"] as? String,
let group = Group(rawValue: groupName)
else {
return nil
}
self.id = id
self.title = title
self.symbol = symbol
self.group = group
isDestructive = arguments["destructive"] as? Bool ?? false
}
}
enum NativeMessageActionSurfaceLayout {
static let minimumRowHeight: CGFloat = 48
static let rowVerticalPadding: CGFloat = 4
static let separatorHeight: CGFloat = 0.5
static let verticalInset: CGFloat = 4
static let horizontalInset: CGFloat = 16
static let iconColumnWidth: CGFloat = 32
static let iconToTextSpacing: CGFloat = 12
static var cornerRadius: CGFloat {
if #available(iOS 26.0, *) {
return 33
}
return 12
}
static func populatedGroups(
actions: [NativeMessageActionDefinition]
) -> [NativeMessageActionDefinition.Group] {
NativeMessageActionDefinition.Group.allCases.filter { group in
actions.contains { $0.group == group }
}
}
static func separatorCount(
actions: [NativeMessageActionDefinition]
) -> Int {
max(0, populatedGroups(actions: actions).count - 1)
}
static func rowHeight(
minimumHeight: CGFloat = minimumRowHeight,
compatibleWith traitCollection: UITraitCollection? = nil
) -> CGFloat {
let labelHeight = UIFont.preferredFont(
forTextStyle: .body,
compatibleWith: traitCollection
).lineHeight
return max(
minimumHeight,
ceil(labelHeight + (rowVerticalPadding * 2))
)
}
static func preferredHeight(
actions: [NativeMessageActionDefinition],
minimumRowHeight: CGFloat = minimumRowHeight,
compatibleWith traitCollection: UITraitCollection? = nil
) -> CGFloat {
let resolvedRowHeight = rowHeight(
minimumHeight: minimumRowHeight,
compatibleWith: traitCollection
)
return (verticalInset * 2)
+ (CGFloat(actions.count) * resolvedRowHeight)
+ (CGFloat(separatorCount(actions: actions)) * separatorHeight)
}
}
@available(iOS 16.0, *)
enum NativeMessageActionSurfaceAppearance {
// The native list is only one sibling inside the Flutter-owned dialog.
// Keeping it non-modal leaves the reaction tray and dismiss barrier
// reachable to VoiceOver.
static let actionListAccessibilityViewIsModal = false
static func interfaceStyle(from value: Any?) -> UIUserInterfaceStyle {
switch value as? String {
case "dark":
return .dark
case "light":
return .light
default:
return .unspecified
}
}
static func backdropEffect(reduceTransparency: Bool) -> UIVisualEffect? {
guard !reduceTransparency else { return nil }
if #available(iOS 26.0, *) {
let effect = UIGlassEffect(style: .regular)
effect.isInteractive = true
return effect
}
return UIBlurEffect(style: .systemMaterial)
}
}
@available(iOS 16.0, *)
final class NativeMessageActionRowControl: UIControl {
let actionImageView: UIImageView
let actionTitleLabel = UILabel()
init(
definition: NativeMessageActionDefinition,
foregroundColor: UIColor,
destructiveColor: UIColor,
minimumHeight: CGFloat = NativeMessageActionSurfaceLayout.minimumRowHeight,
compatibleWith traitCollection: UITraitCollection? = nil,
onSelected: @escaping () -> Void
) {
actionImageView = UIImageView(image: NativeTabler.image(definition.symbol))
super.init(frame: .zero)
let color = definition.isDestructive ? destructiveColor : foregroundColor
actionImageView.tintColor = color
actionImageView.contentMode = .center
actionTitleLabel.text = definition.title
actionTitleLabel.textColor = color
actionTitleLabel.font = UIFont.preferredFont(
forTextStyle: .body,
compatibleWith: traitCollection
)
actionTitleLabel.adjustsFontForContentSizeCategory = true
actionTitleLabel.numberOfLines = 0
actionTitleLabel.lineBreakMode = .byWordWrapping
actionTitleLabel.setContentCompressionResistancePriority(
.required,
for: .vertical
)
let iconColumn = UIView()
// Decorative content must leave the entire row tappable.
iconColumn.isUserInteractionEnabled = false
iconColumn.translatesAutoresizingMaskIntoConstraints = false
actionImageView.translatesAutoresizingMaskIntoConstraints = false
actionTitleLabel.translatesAutoresizingMaskIntoConstraints = false
iconColumn.addSubview(actionImageView)
addSubview(iconColumn)
addSubview(actionTitleLabel)
NSLayoutConstraint.activate([
iconColumn.leadingAnchor.constraint(
equalTo: leadingAnchor,
constant: NativeMessageActionSurfaceLayout.horizontalInset
),
iconColumn.centerYAnchor.constraint(equalTo: centerYAnchor),
iconColumn.widthAnchor.constraint(
equalToConstant: NativeMessageActionSurfaceLayout.iconColumnWidth
),
iconColumn.heightAnchor.constraint(
equalToConstant: NativeMessageActionSurfaceLayout.iconColumnWidth
),
actionImageView.leadingAnchor.constraint(equalTo: iconColumn.leadingAnchor),
actionImageView.trailingAnchor.constraint(equalTo: iconColumn.trailingAnchor),
actionImageView.topAnchor.constraint(equalTo: iconColumn.topAnchor),
actionImageView.bottomAnchor.constraint(equalTo: iconColumn.bottomAnchor),
actionTitleLabel.leadingAnchor.constraint(
equalTo: iconColumn.trailingAnchor,
constant: NativeMessageActionSurfaceLayout.iconToTextSpacing
),
actionTitleLabel.trailingAnchor.constraint(
equalTo: trailingAnchor,
constant: -NativeMessageActionSurfaceLayout.horizontalInset
),
actionTitleLabel.topAnchor.constraint(
greaterThanOrEqualTo: topAnchor,
constant: NativeMessageActionSurfaceLayout.rowVerticalPadding
),
actionTitleLabel.bottomAnchor.constraint(
lessThanOrEqualTo: bottomAnchor,
constant: -NativeMessageActionSurfaceLayout.rowVerticalPadding
),
actionTitleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
heightAnchor.constraint(
greaterThanOrEqualToConstant: NativeMessageActionSurfaceLayout.rowHeight(
minimumHeight: minimumHeight,
compatibleWith: traitCollection
)
),
])
accessibilityLabel = definition.title
accessibilityTraits = .button
isAccessibilityElement = true
addAction(UIAction { _ in onSelected() }, for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is unavailable")
}
override var isHighlighted: Bool {
didSet {
backgroundColor =
isHighlighted
? actionTitleLabel.textColor.withAlphaComponent(0.08)
: .clear
}
}
}
@available(iOS 16.0, *)
final class NativeMessageActionSeparatorView: UIView {
init(color: UIColor) {
super.init(frame: .zero)
backgroundColor = color
heightAnchor.constraint(
equalToConstant: NativeMessageActionSurfaceLayout.separatorHeight
).isActive = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is unavailable")
}
}
@available(iOS 16.0, *)
final class NativeMessageActionSurfaceFactory: NSObject,
FlutterPlatformViewFactory
{
private let messenger: FlutterBinaryMessenger
init(messenger: FlutterBinaryMessenger) {
self.messenger = messenger
super.init()
}
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
FlutterStandardMessageCodec.sharedInstance()
}
func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
NativeMessageActionSurfacePlatformView(
frame: frame,
viewIdentifier: viewId,
messenger: messenger,
arguments: args
)
}
}
@available(iOS 16.0, *)
final class NativeMessageActionSurfacePlatformView: NSObject,
FlutterPlatformView
{
private let surfaceView: UIView
private let backdropView: UIVisualEffectView
private let channel: FlutterMethodChannel
init(
frame: CGRect,
viewIdentifier viewId: Int64,
messenger: FlutterBinaryMessenger,
arguments args: Any?
) {
let arguments = args as? [String: Any]
let surfaceColor = Self.color(
from: arguments?["surfaceColor"],
fallback: .systemBackground
)
let foregroundColor = Self.color(
from: arguments?["foregroundColor"],
fallback: .label
)
let separatorColor = Self.color(
from: arguments?["separatorColor"],
fallback: .separator
)
let destructiveColor = Self.color(
from: arguments?["errorColor"],
fallback: .systemRed
)
let interfaceStyle = NativeMessageActionSurfaceAppearance.interfaceStyle(
from: arguments?["interfaceStyle"]
)
var minimumRowHeight = NativeMessageActionSurfaceLayout.minimumRowHeight
if let requestedRowHeight = arguments?["rowHeight"] as? NSNumber,
requestedRowHeight.doubleValue.isFinite
{
minimumRowHeight = max(
minimumRowHeight,
CGFloat(requestedRowHeight.doubleValue)
)
}
let actionArguments = arguments?["actions"] as? [[String: Any]]
let actions =
actionArguments?.compactMap(
NativeMessageActionDefinition.init(arguments:)
) ?? []
surfaceView = UIView(frame: frame)
backdropView = UIVisualEffectView(
effect: NativeMessageActionSurfaceAppearance.backdropEffect(
reduceTransparency: UIAccessibility.isReduceTransparencyEnabled
)
)
channel = FlutterMethodChannel(
name: "buzz/native_message_action_surface/\(viewId)",
binaryMessenger: messenger
)
super.init()
surfaceView.backgroundColor = .clear
surfaceView.clipsToBounds = false
surfaceView.accessibilityViewIsModal =
NativeMessageActionSurfaceAppearance.actionListAccessibilityViewIsModal
surfaceView.overrideUserInterfaceStyle = interfaceStyle
backdropView.translatesAutoresizingMaskIntoConstraints = false
backdropView.overrideUserInterfaceStyle = interfaceStyle
backdropView.layer.cornerRadius = NativeMessageActionSurfaceLayout.cornerRadius
backdropView.layer.cornerCurve = .continuous
backdropView.layer.masksToBounds = true
if backdropView.effect == nil {
backdropView.backgroundColor = surfaceColor
}
surfaceView.addSubview(backdropView)
NSLayoutConstraint.activate([
backdropView.leadingAnchor.constraint(equalTo: surfaceView.leadingAnchor),
backdropView.trailingAnchor.constraint(equalTo: surfaceView.trailingAnchor),
backdropView.topAnchor.constraint(equalTo: surfaceView.topAnchor),
backdropView.bottomAnchor.constraint(equalTo: surfaceView.bottomAnchor),
])
if #unavailable(iOS 26.0) {
surfaceView.layer.cornerRadius = NativeMessageActionSurfaceLayout.cornerRadius
surfaceView.layer.shadowRadius = 32
surfaceView.layer.shadowOffset = CGSize(width: 0, height: 16)
surfaceView.layer.shadowColor = UIColor.black.cgColor
surfaceView.layer.shadowOpacity = 0.2
}
install(
actions: actions,
foregroundColor: foregroundColor,
destructiveColor: destructiveColor,
separatorColor: separatorColor,
minimumRowHeight: minimumRowHeight
)
}
func view() -> UIView {
surfaceView
}
private func install(
actions: [NativeMessageActionDefinition],
foregroundColor: UIColor,
destructiveColor: UIColor,
separatorColor: UIColor,
minimumRowHeight: CGFloat
) {
let scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.alwaysBounceVertical = false
scrollView.showsVerticalScrollIndicator = false
scrollView.contentInsetAdjustmentBehavior = .never
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 0
stack.translatesAutoresizingMaskIntoConstraints = false
backdropView.contentView.addSubview(scrollView)
scrollView.addSubview(stack)
NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(equalTo: backdropView.contentView.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: backdropView.contentView.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: backdropView.contentView.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: backdropView.contentView.bottomAnchor),
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
stack.topAnchor.constraint(
equalTo: scrollView.contentLayoutGuide.topAnchor,
constant: NativeMessageActionSurfaceLayout.verticalInset
),
stack.bottomAnchor.constraint(
equalTo: scrollView.contentLayoutGuide.bottomAnchor,
constant: -NativeMessageActionSurfaceLayout.verticalInset
),
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
])
var installedGroup = false
for group in NativeMessageActionDefinition.Group.allCases {
let groupActions = actions.filter { $0.group == group }
guard !groupActions.isEmpty else { continue }
if installedGroup {
stack.addArrangedSubview(
NativeMessageActionSeparatorView(color: separatorColor)
)
}
for definition in groupActions {
stack.addArrangedSubview(
NativeMessageActionRowControl(
definition: definition,
foregroundColor: foregroundColor,
destructiveColor: destructiveColor,
minimumHeight: minimumRowHeight,
onSelected: { [weak self] in self?.select(definition) }
)
)
}
installedGroup = true
}
}
private func select(_ definition: NativeMessageActionDefinition) {
channel.invokeMethod("selected", arguments: ["id": definition.id])
}
private static func color(from value: Any?, fallback: UIColor) -> UIColor {
guard let number = value as? NSNumber else { return fallback }
let color = number.uint32Value
let alpha = CGFloat((color >> 24) & 0xFF) / 255
let red = CGFloat((color >> 16) & 0xFF) / 255
let green = CGFloat((color >> 8) & 0xFF) / 255
let blue = CGFloat(color & 0xFF) / 255
return UIColor(red: red, green: green, blue: blue, alpha: alpha)
}
}