mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
## 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>
456 lines
14 KiB
Swift
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)
|
|
}
|
|
}
|