Files
buzz/mobile/lib/shared/widgets/avatar_image_native.dart
klopez4212 6cf4582002 Use native iOS navigation bars with large titles and avatars (#8052)
## Summary

Use UIKit navigation bars across iOS pages, with large titles that
collapse into the toolbar on scroll and a light native blur. Home uses
the active community name as its title and shows community and profile
avatars with centered emoji and stable loading, without flashing back to
icons. Channel headers retain the member count, tap-to-settings title,
and Huddle action. Their ultra-thin native material has a soft lower
edge and stays visible at the timeline bottom. The hidden title-layout
scroll view has its automatic edge effects disabled so it cannot add a
second backdrop.

| Expanded Home | Scrolled Home | Settings |
| --- | --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--home.png"
alt="home" width="240"> | <img
src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--scrolled.png"
alt="scrolled" width="240"> | <img
src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--settings.png"
alt="settings" width="240"> |

Channel title, member count, and Huddle control:

<img
src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--channel.png"
alt="Channel navigation" width="280">

### Related issue

No matching open issue found.

### Testing

- Added causal regressions for Profile/Edit Photo native titles,
metrics-only collapse changes, and native-header suppression throughout
channel/thread reaction and action backdrops. Each regression fails when
its corresponding fix is removed.

- Mobile formatting and analysis passed. Full mobile test rerun passed:
2,622 tests, 4 skipped, plus 3 push-disabled build tests.
- Native iPhone Air regression passed: repeated title expansion/collapse
and scroll material behavior.
- Navigation and blur were accepted on the connected iPhone using the
existing profile. Simulator snapshots use real community data. The
subsequent review fixes have widget coverage; fresh physical-device
verification of Profile states, Dynamic Type changes, and message
backdrops is pending.
- Full repository `just ci` was blocked by local disk exhaustion while
compiling Rust dependencies.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-03 18:10:03 +01:00

204 lines
6.1 KiB
Dart

part of 'avatar_image.dart';
/// Renders the same avatar sources used by Flutter as a PNG for native bars.
Future<Uint8List?> nativeAvatarImage({
required String? url,
required String initial,
bool isAgent = false,
required Color background,
required Color foreground,
required ImageProvider Function(String) networkImage,
}) async {
const dimension = 72.0;
final poster = parseAnimatedAvatarUrl(url)?.posterUrl ?? url;
final source = _AvatarSource.parse(poster);
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final bounds = const Rect.fromLTWH(0, 0, dimension, dimension);
canvas.clipRRect(
RRect.fromRectAndRadius(
bounds,
Radius.circular(dimension * (isAgent ? 0.3 : 0.5)),
),
);
canvas.drawColor(background, BlendMode.src);
var rendered = false;
try {
switch (source) {
case _EmojiAvatarSource(:final emoji, :final color):
canvas.drawColor(color, BlendMode.src);
await _paintNativeAvatarEmoji(
canvas,
emoji,
dimension * 258 / 512,
foreground,
);
rendered = true;
case _SvgAvatarSource(:final svg):
final picture = await vg.loadPicture(SvgStringLoader(svg), null);
final scale = dimension / picture.size.shortestSide;
canvas.save();
canvas.translate(
(dimension - picture.size.width * scale) / 2,
(dimension - picture.size.height * scale) / 2,
);
canvas.scale(scale);
canvas.drawPicture(picture.picture);
canvas.restore();
picture.picture.dispose();
rendered = true;
case _RasterDataAvatarSource(:final bytes):
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
final codec = await ui.instantiateImageCodecWithSize(
buffer,
getTargetSize: (width, height) => width >= height
? ui.TargetImageSize(width: width.clamp(1, dimension.toInt()))
: ui.TargetImageSize(height: height.clamp(1, dimension.toInt())),
);
try {
final frame = await codec.getNextFrame();
paintImage(
canvas: canvas,
rect: bounds,
image: frame.image,
fit: BoxFit.cover,
);
frame.image.dispose();
} finally {
codec.dispose();
}
rendered = true;
case _NetworkAvatarSource(:final url):
final stream = ResizeImage(
networkImage(url),
width: dimension.toInt(),
height: dimension.toInt(),
policy: ResizeImagePolicy.fit,
).resolve(ImageConfiguration.empty);
final completer = Completer<ImageInfo>();
final listener = ImageStreamListener(
(image, _) {
if (!completer.isCompleted) completer.complete(image.clone());
},
onError: (Object error, StackTrace? stack) {
if (!completer.isCompleted) completer.completeError(error, stack);
},
);
stream.addListener(listener);
try {
final image = await completer.future.timeout(
const Duration(seconds: 10),
);
paintImage(
canvas: canvas,
rect: bounds,
image: image.image,
fit: BoxFit.cover,
);
image.dispose();
rendered = true;
} finally {
stream.removeListener(listener);
}
case null:
break;
}
} catch (_) {
// Match the normal avatar's initial fallback when media is unavailable.
}
if (!rendered) _paintNativeAvatarText(canvas, initial, 32, foreground);
final picture = recorder.endRecording();
final image = await picture.toImage(dimension.toInt(), dimension.toInt());
picture.dispose();
try {
return (await image.toByteData(
format: ui.ImageByteFormat.png,
))?.buffer.asUint8List();
} finally {
image.dispose();
}
}
void _paintNativeAvatarText(
Canvas canvas,
String text,
double size,
Color color,
) {
final painter = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(fontSize: size, color: color, height: 1),
),
textDirection: TextDirection.ltr,
)..layout();
painter.paint(
canvas,
Offset((72 - painter.width) / 2, (72 - painter.height) / 2),
);
painter.dispose();
}
// Text advances and baselines are not the visible bounds of a color emoji.
// Rasterize on a padded transparent surface, then center the painted pixels.
Future<void> _paintNativeAvatarEmoji(
Canvas canvas,
String emoji,
double size,
Color color,
) async {
final painter = TextPainter(
text: TextSpan(
text: emoji,
style: TextStyle(fontSize: size, color: color),
),
textDirection: TextDirection.ltr,
)..layout();
final width = (painter.width + size * 2).ceil();
final height = (painter.height + size * 2).ceil();
final recorder = ui.PictureRecorder();
painter.paint(Canvas(recorder), Offset(size, size));
painter.dispose();
final picture = recorder.endRecording();
final image = await picture.toImage(width, height);
picture.dispose();
try {
final pixels = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
if (pixels == null) return;
var left = width;
var top = height;
var right = -1;
var bottom = -1;
for (var y = 0; y < height; y++) {
for (var x = 0; x < width; x++) {
if (pixels.getUint8((y * width + x) * 4 + 3) < 8) continue;
if (x < left) left = x;
if (x > right) right = x;
if (y < top) top = y;
if (y > bottom) bottom = y;
}
}
if (right < left || bottom < top) return;
final ink = Rect.fromLTRB(
left.toDouble(),
top.toDouble(),
right + 1.0,
bottom + 1.0,
);
final longest = ink.width > ink.height ? ink.width : ink.height;
final scale = (size / longest).clamp(0.0, 1.0);
canvas.drawImageRect(
image,
ink,
Rect.fromCenter(
center: const Offset(36, 36),
width: ink.width * scale,
height: ink.height * scale,
),
Paint()..filterQuality = FilterQuality.high,
);
} finally {
image.dispose();
}
}