mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
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>
198 lines
5.4 KiB
Dart
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);
|
|
}
|