Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
807a6b7
fix(mobile): harden profile photo editing
klopez4212 Aug 23, 2026
e86a8e9
test(mobile): cover animated upload retries
klopez4212 Aug 23, 2026
b40945c
fix(mobile): orient animated avatar frames
klopez4212 Aug 23, 2026
66267bb
fix(mobile): merge profile edits with relay head
klopez4212 Aug 23, 2026
eb78eac
fix(mobile): retain concurrent profile edits
klopez4212 Aug 23, 2026
94ee6be
fix(mobile): scope profile writes to community
klopez4212 Aug 23, 2026
b9ccbdc
test(mobile): bound avatar crop settling
klopez4212 Aug 23, 2026
b52063e
fix(mobile): preserve avatar work across context changes
klopez4212 Aug 23, 2026
bb3d657
fix(mobile): keep profile edits in their origin context
klopez4212 Aug 23, 2026
678b642
fix(mobile): close stale profile text drafts
klopez4212 Aug 23, 2026
c5dfeb5
fix(mobile): cancel orphaned profile editors
klopez4212 Aug 23, 2026
f3f2c2d
fix(mobile): finalize profile editor state
klopez4212 Aug 23, 2026
045bfef
fix(mobile): isolate profile hydration context
klopez4212 Aug 23, 2026
dbb06ea
fix(mobile): keep native profile retries actionable
klopez4212 Aug 23, 2026
f9894f9
fix(mobile): isolate profile editor sessions
klopez4212 Aug 23, 2026
799a768
fix(mobile): preserve avatar editing context
klopez4212 Aug 23, 2026
2e39c0a
test(mobile): split profile editor coverage
klopez4212 Aug 23, 2026
ff8a34b
fix(mobile): preserve profile event tags
klopez4212 Aug 23, 2026
3baa908
fix(mobile): close profile edit edge cases
klopez4212 Aug 23, 2026
1a05c9c
docs(mobile): complete profile editor API docs
klopez4212 Aug 23, 2026
11b6c0c
fix(mobile): discard stale avatar image selections
klopez4212 Aug 23, 2026
00b1006
fix(mobile): release animated avatar camera in review
klopez4212 Aug 23, 2026
c067eef
fix(mobile): expose avatar selection semantics
klopez4212 Aug 23, 2026
c781ac6
fix(mobile): scope profile editing context
klopez4212 Aug 23, 2026
84eef9f
fix(ios): match profile editor theme
klopez4212 Aug 23, 2026
e4b6e54
fix(mobile): preserve profile edit state
klopez4212 Aug 23, 2026
747e01c
fix(mobile): harden profile editing
klopez4212 Aug 23, 2026
d15ab8d
fix(mobile): complete avatar editor accessibility
klopez4212 Aug 23, 2026
8c74dfa
fix(mobile): make photo cropping accessible
klopez4212 Aug 23, 2026
7188a98
fix(mobile): guard avatar editor races
klopez4212 Aug 23, 2026
72b0f96
fix(mobile): finish avatar capture review
klopez4212 Aug 23, 2026
102c99d
fix(mobile): refine avatar mode motion
klopez4212 Aug 23, 2026
9929efa
fix(mobile): smooth emoji animated handoff
klopez4212 Aug 23, 2026
c0ba9e1
feat(mobile): add inline profile photo capture
klopez4212 Aug 24, 2026
4b1f8a1
fix(mobile): refine profile camera motion
klopez4212 Aug 24, 2026
5452792
fix(mobile): polish profile camera capture motion
klopez4212 Aug 24, 2026
482d83c
fix(mobile): smooth camera state handoffs
klopez4212 Aug 24, 2026
4b82198
fix(mobile): stabilize emoji editor layout
klopez4212 Aug 24, 2026
b5bfaa0
fix(mobile): refine profile camera controls
klopez4212 Aug 24, 2026
a56c074
fix(mobile): polish avatar capture feedback
klopez4212 Aug 24, 2026
4ca6bdd
fix(mobile): crossfade camera exit controls
klopez4212 Aug 24, 2026
089dd30
fix(mobile): complete camera exit handoff
klopez4212 Aug 24, 2026
668c1d5
fix(mobile): synchronize camera preview expansion
klopez4212 Aug 24, 2026
990275d
fix(mobile): scale avatar through camera handoff
klopez4212 Aug 24, 2026
2038112
refactor(mobile): keep avatar capture within size limit
klopez4212 Aug 24, 2026
4b8f018
fix(mobile): resize profile camera shutter
klopez4212 Aug 24, 2026
9701142
fix(mobile): enlarge profile camera shutter
klopez4212 Aug 24, 2026
09b919a
feat(mobile): animate profile camera flips
klopez4212 Aug 24, 2026
1ae1feb
fix(mobile): polish profile camera selection
klopez4212 Aug 24, 2026
ce9627f
fix(mobile): lock profile capture to portrait
klopez4212 Aug 24, 2026
5076f70
fix(mobile): preserve animated avatar save handoff
klopez4212 Aug 24, 2026
469afa7
fix(mobile): hide capture placeholder during processing
klopez4212 Aug 24, 2026
ad6e31c
fix(mobile): save latest animated avatar framing
klopez4212 Aug 24, 2026
7020f15
fix(mobile): preserve animated avatar frame bounds
klopez4212 Aug 24, 2026
f4351d4
fix(mobile): preserve enlarged animated avatar framing
klopez4212 Aug 24, 2026
94c1ca5
fix(mobile): harden profile camera capture
Aug 24, 2026
f7cb63c
perf(desktop): persist channel heads, collapse thread reads and reply…
tlongwell-block Aug 24, 2026
b8d64a4
fix(desktop): emit singular `mention` feed category so alerts route c…
morgmart Aug 24, 2026
dd9b232
fix(mobile): recover camera after disposal failure
Aug 24, 2026
76643f1
fix(mobile): remove obsolete animated error part
Aug 24, 2026
f704ca8
test(mobile): cover profile camera recovery
Aug 25, 2026
a0dd6c3
test(mobile): cover animated camera lifecycle recovery
Aug 25, 2026
6caf85b
fix(mobile): clear stale gallery errors in camera
Aug 25, 2026
b597a93
fix(mobile): serialize profile camera ownership
Aug 25, 2026
a33e1fa
test(mobile): cover in-flight camera lifecycle ownership
Aug 25, 2026
6c9f194
fix(mobile): address camera capture review findings
Aug 25, 2026
431e8a2
test(mobile): cover camera teardown settlement
Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 150 additions & 34 deletions mobile/ios/Runner/JumpToLatestGlassButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,18 @@ final class NavigationGlassButtonFactory: NSObject, FlutterPlatformViewFactory {
}

final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
private static let shutterIconRatio: CGFloat = 80.0 / 115.0
private static let shutterInsetRatio: CGFloat = 20.0 / 115.0
private let containerView: UIView
private let channel: FlutterMethodChannel
private let button = NavigationGlassButton(type: .system)
private let activityIndicator = UIActivityIndicatorView(style: .medium)
private var buttonLabel: String?
private var buttonIconName = "chevron.backward"
private var contentIcon = "back"
private var buttonImage: UIImage?
private var isBusy = false
private var controlSize: CGFloat = 40

init(
frame: CGRect,
Expand All @@ -196,12 +204,11 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
(arguments?["hitTargetWidth"] as? NSNumber)?.doubleValue ?? 48
let hitTargetHeight =
(arguments?["hitTargetHeight"] as? NSNumber)?.doubleValue ?? 48
let label = arguments?["label"] as? String
buttonLabel = label
let icon = arguments?["icon"] as? String
let symbolName = icon == "close" ? "xmark" : "chevron.backward"
let controlWidth =
(arguments?["controlWidth"] as? NSNumber)?.doubleValue ?? 40
controlSize =
(arguments?["controlSize"] as? NSNumber)?.doubleValue ?? 40
let fillWidth = arguments?["fillWidth"] as? Bool ?? false

var configuration: UIButton.Configuration
if #available(iOS 26.0, *) {
Expand All @@ -211,33 +218,15 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
configuration.baseBackgroundColor = UIColor.secondarySystemBackground
}
configuration.cornerStyle = .capsule
if let label {
configuration.title = label
configuration.titleLineBreakMode = .byClipping
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer {
incoming in
var outgoing = incoming
let preferred = UIFont.preferredFont(forTextStyle: .subheadline)
outgoing.font = UIFont.systemFont(ofSize: preferred.pointSize, weight: .semibold)
return outgoing
}
} else {
configuration.image = UIImage(
systemName: symbolName,
withConfiguration: UIImage.SymbolConfiguration(
pointSize: 17,
weight: .semibold
)
)
}
button.configuration = configuration
applyContent(from: arguments)
button.titleLabel?.numberOfLines = 1
button.titleLabel?.lineBreakMode = .byClipping
button.hitTargetInsets = UIEdgeInsets(
top: max(0, (hitTargetHeight - 40) / 2),
left: max(0, buttonCenterX - 20),
bottom: max(0, (hitTargetHeight - 40) / 2),
right: max(0, hitTargetWidth - buttonCenterX - 20)
top: max(0, (hitTargetHeight - controlSize) / 2),
left: max(0, buttonCenterX - controlWidth / 2),
bottom: max(0, (hitTargetHeight - controlSize) / 2),
right: max(0, hitTargetWidth - buttonCenterX - controlWidth / 2)
)
button.accessibilityLabel = arguments?["accessibilityLabel"] as? String ?? "Back"
button.translatesAutoresizingMaskIntoConstraints = false
Expand All @@ -248,8 +237,24 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
for: .touchUpInside
)

activityIndicator.translatesAutoresizingMaskIntoConstraints = false
activityIndicator.hidesWhenStopped = true
activityIndicator.isUserInteractionEnabled = false
button.addSubview(activityIndicator)
NSLayoutConstraint.activate([
activityIndicator.centerXAnchor.constraint(equalTo: button.centerXAnchor),
activityIndicator.centerYAnchor.constraint(equalTo: button.centerYAnchor),
activityIndicator.widthAnchor.constraint(equalToConstant: 24),
activityIndicator.heightAnchor.constraint(equalToConstant: 24),
])

applyAppearance(from: args)
channel.setMethodCallHandler { [weak self] call, result in
if call.method == "setContent" {
self?.setContent(from: call.arguments)
result(nil)
return
}
guard call.method == "setAppearance" else {
result(FlutterMethodNotImplemented)
return
Expand All @@ -260,14 +265,19 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {

containerView.addSubview(button)
NSLayoutConstraint.activate([
button.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
button.heightAnchor.constraint(equalToConstant: controlSize),
])
if fillWidth {
button.centerXAnchor.constraint(equalTo: containerView.centerXAnchor).isActive = true
button.widthAnchor.constraint(equalTo: containerView.widthAnchor).isActive = true
} else {
button.centerXAnchor.constraint(
equalTo: containerView.leadingAnchor,
constant: buttonCenterX
),
button.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
button.widthAnchor.constraint(equalToConstant: controlWidth),
button.heightAnchor.constraint(equalToConstant: 40),
])
).isActive = true
button.widthAnchor.constraint(equalToConstant: controlWidth).isActive = true
}
}

func view() -> UIView {
Expand Down Expand Up @@ -331,24 +341,130 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
let foregroundColor = colorValue.map(Self.color(from:))
let enabled = arguments?["enabled"] as? Bool ?? true
let busy = arguments?["busy"] as? Bool ?? false
let selected = arguments?["selected"] as? Bool ?? false

containerView.overrideUserInterfaceStyle = interfaceStyle
button.overrideUserInterfaceStyle = interfaceStyle
button.isEnabled = enabled
button.configuration?.showsActivityIndicator = busy
button.configuration?.title = busy ? nil : buttonLabel
button.isSelected = selected
isBusy = busy
if selected {
button.accessibilityTraits.insert(.selected)
} else {
button.accessibilityTraits.remove(.selected)
}
button.configuration?.showsActivityIndicator = false
if let foregroundColor {
// Glass uses the view tint for its selected treatment. Keep it aligned
// with the Buzz theme instead of falling back to the system blue tint.
button.tintColor = foregroundColor
button.configuration?.baseForegroundColor = foregroundColor
activityIndicator.color = foregroundColor
}
if #unavailable(iOS 26.0) {
button.configuration?.baseBackgroundColor = Self.fallbackBackgroundColor(
foregroundColor: foregroundColor,
interfaceStyle: interfaceStyle
)
}
updateDisplayedContent()
button.setNeedsUpdateConfiguration()
}

private func applyContent(from value: Any?) {
let arguments = value as? [String: Any]
let icon = arguments?["icon"] as? String
contentIcon = icon ?? "back"
buttonLabel = arguments?["label"] as? String
if let accessibilityLabel = arguments?["accessibilityLabel"] as? String {
button.accessibilityLabel = accessibilityLabel
}
switch icon {
case "close": buttonIconName = "xmark"
case "camera": buttonIconName = "camera"
case "photoLibrary": buttonIconName = "photo.on.rectangle.angled"
case "palette": buttonIconName = "paintpalette"
case "emoji": buttonIconName = "face.smiling"
case "person": buttonIconName = "person"
case "frame": buttonIconName = "rectangle.stack"
case "rotateCamera": buttonIconName = "arrow.triangle.2.circlepath.camera"
case "shutter": buttonIconName = "circle.fill"
default: buttonIconName = "chevron.backward"
}
if buttonLabel != nil {
buttonImage = nil
button.configuration?.contentInsets = NSDirectionalEdgeInsets(
top: 8,
leading: 8,
bottom: 8,
trailing: 8
)
button.configuration?.titleLineBreakMode = .byClipping
button.configuration?.titleTextAttributesTransformer =
UIConfigurationTextAttributesTransformer { incoming in
var outgoing = incoming
let preferred = UIFont.preferredFont(forTextStyle: .subheadline)
outgoing.font = UIFont.systemFont(
ofSize: preferred.pointSize,
weight: .semibold
)
return outgoing
}
} else {
button.configuration?.titleTextAttributesTransformer = nil
let iconInset: CGFloat = icon == "shutter"
? controlSize * Self.shutterInsetRatio
: 8
button.configuration?.contentInsets = NSDirectionalEdgeInsets(
top: iconInset,
leading: iconInset,
bottom: iconInset,
trailing: iconInset
)
let pointSize: CGFloat = icon == "shutter"
? controlSize * Self.shutterIconRatio
: 17
buttonImage = UIImage(
systemName: buttonIconName,
withConfiguration: UIImage.SymbolConfiguration(
pointSize: pointSize,
weight: .semibold
)
)
}
updateDisplayedContent()
button.setNeedsUpdateConfiguration()
}

private func setContent(from value: Any?) {
let arguments = value as? [String: Any]
let nextIcon = arguments?["icon"] as? String ?? "back"
let nextLabel = arguments?["label"] as? String
guard nextIcon != contentIcon || nextLabel != buttonLabel else {
applyContent(from: value)
return
}
let duration = UIAccessibility.isReduceMotionEnabled ? 0 : 0.12
UIView.transition(
with: button,
duration: duration,
options: [.transitionCrossDissolve, .beginFromCurrentState, .allowAnimatedContent],
animations: { [weak self] in self?.applyContent(from: value) }
)
}

private func updateDisplayedContent() {
if isBusy {
button.configuration?.title = nil
button.configuration?.image = nil
activityIndicator.startAnimating()
} else {
button.configuration?.title = buttonLabel
button.configuration?.image = buttonLabel == nil ? buttonImage : nil
activityIndicator.stopAnimating()
}
}

private static func color(from value: UInt32) -> UIColor {
let alpha = CGFloat((value >> 24) & 0xFF) / 255
let red = CGFloat((value >> 16) & 0xFF) / 255
Expand Down
Loading
Loading