Files
buzz/mobile/lib/shared/widgets/avatar_image.dart
Tom Brow 51559c75c7 refactor(mobile): route avatar shapes through one bottleneck
avatar_shape.dart now owns the agent squircle and the person circle. avatarShape(isAgent:), avatarPath() and AvatarClip are the only way to get either, and the squircle border is private. A source guard test fails when code keys an avatar shape on agent identity inline, or uses a person shape on an agent-only surface.

Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@squareup.com>
2026-10-31 10:25:54 -07:00

198 lines
5.4 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:ui' as ui;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../animated_avatar.dart';
import '../emoji/emoji_avatar.dart';
import '../emoji/native_emoji_glyph.dart';
import '../relay/relay.dart';
import 'avatar_shape.dart';
part 'avatar_image_native.dart';
/// An avatar that supports both remote URLs and inline image data.
///
/// Flutter's [NetworkImage] only loads network URLs, while desktop browsers also
/// accept `data:image/*` sources directly. Agent emoji avatars are inline SVGs,
/// so mobile must decode those before rendering them.
class AvatarImage extends StatelessWidget {
final String? imageUrl;
final double radius;
final Color? backgroundColor;
final Widget fallback;
final bool isAgent;
const AvatarImage({
super.key,
required this.imageUrl,
required this.radius,
required this.fallback,
this.backgroundColor,
this.isAgent = false,
});
@override
Widget build(BuildContext context) {
final animatedAvatar = parseAnimatedAvatarUrl(imageUrl);
final color = animatedAvatar == null ? backgroundColor : Colors.transparent;
final content = SizedBox.square(
dimension: radius * 2,
child: AvatarImageContent(
imageUrl: animatedAvatar?.posterUrl ?? imageUrl,
fallback: fallback,
),
);
final clipped = AvatarClip(
isAgent: isAgent,
child: isAgent && usesInsetAgentArtwork(imageUrl)
? Transform.scale(scale: animatedAvatarArtworkScale, child: content)
: content,
);
// CircleAvatar keeps its theme default when people have no background.
if (!isAgent) {
return CircleAvatar(
radius: radius,
backgroundColor: color,
child: clipped,
);
}
return DecoratedBox(
decoration: ShapeDecoration(
color: color,
shape: avatarShape(isAgent: true),
),
child: clipped,
);
}
}
/// Image content for avatar surfaces whose shape is supplied by their parent.
class AvatarImageContent extends ConsumerStatefulWidget {
final String? imageUrl;
final Widget fallback;
final BoxFit fit;
const AvatarImageContent({
super.key,
required this.imageUrl,
required this.fallback,
this.fit = BoxFit.cover,
});
@override
ConsumerState<AvatarImageContent> createState() => _AvatarImageContentState();
}
class _AvatarImageContentState extends ConsumerState<AvatarImageContent> {
late _AvatarSource? _source = _AvatarSource.parse(widget.imageUrl);
@override
void didUpdateWidget(AvatarImageContent oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.imageUrl != oldWidget.imageUrl) {
_source = _AvatarSource.parse(widget.imageUrl);
}
}
@override
Widget build(BuildContext context) {
final centeredFallback = Center(child: widget.fallback);
return switch (_source) {
_EmojiAvatarSource(:final emoji, :final color) => ColoredBox(
color: color,
child: LayoutBuilder(
builder: (_, constraints) {
final glyphSize = constraints.biggest.shortestSide * 258 / 512;
return Center(
child: NativeEmojiGlyph(
emoji: emoji,
size: glyphSize,
opticalBoxSize: glyphSize,
),
);
},
),
),
_SvgAvatarSource(:final svg) => SvgPicture.string(
svg,
fit: widget.fit,
placeholderBuilder: (_) => centeredFallback,
errorBuilder: (_, _, _) => centeredFallback,
),
_RasterDataAvatarSource(:final bytes) => _rasterImage(
bytes: bytes,
fallback: centeredFallback,
),
_NetworkAvatarSource(:final url) => MediaImage(
url: url,
fit: widget.fit,
errorBuilder: (_, _, _) => centeredFallback,
),
null => centeredFallback,
};
}
Widget _rasterImage({required Uint8List bytes, required Widget fallback}) {
return Image.memory(
bytes,
fit: widget.fit,
errorBuilder: (_, _, _) => fallback,
);
}
}
sealed class _AvatarSource {
const _AvatarSource();
static _AvatarSource? parse(String? value) {
final url = value?.trim();
if (url == null || url.isEmpty) return null;
if (!url.startsWith('data:image/')) return _NetworkAvatarSource(url);
try {
final data = UriData.parse(url);
if (data.mimeType == 'image/svg+xml') {
final Uint8List bytes = data.contentAsBytes();
final svg = utf8.decode(bytes);
final emojiAvatar = parseEmojiAvatarSvg(svg);
return emojiAvatar == null
? _SvgAvatarSource(svg)
: _EmojiAvatarSource(
emojiAvatar.emoji,
Color(emojiAvatar.colorValue),
);
}
return _RasterDataAvatarSource(data.contentAsBytes());
} on FormatException {
return null;
}
}
}
class _EmojiAvatarSource extends _AvatarSource {
final String emoji;
final Color color;
const _EmojiAvatarSource(this.emoji, this.color);
}
class _SvgAvatarSource extends _AvatarSource {
final String svg;
const _SvgAvatarSource(this.svg);
}
class _RasterDataAvatarSource extends _AvatarSource {
final Uint8List bytes;
const _RasterDataAvatarSource(this.bytes);
}
class _NetworkAvatarSource extends _AvatarSource {
final String url;
const _NetworkAvatarSource(this.url);
}