mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
## Summary - render every agent/AI identity as a 30% squircle across desktop and mobile while keeping human avatars circular - propagate agent identity through message, thread, profile, reaction, member, DM, search, workflow, project, huddle, forum, pulse, and agent-management surfaces - preserve squircle geometry for fallbacks, focus/status treatments, add-agent controls, and overlapping avatar outlines (`calc(30% + 2px)` for the outer background) ### Related issue None found. This change was requested and visually reviewed in the originating Buzz thread. ### Testing - `just desktop-test` — 5,799 passed - `just mobile-test` — 2,008 passed - pre-push gates passed at `0d59d77b120dcb90aac2f918e422c11c9fa5353b`: desktop check, TypeScript typecheck, desktop full test suite, mobile format/analyze and full test suite, Rust tests, Tauri checks, and differential file-size gate - deterministic desktop visual sweep covered channel messages/thread summaries; thread, subthread, and sub-subthread depths; reactions and reactor popovers; hover/full profiles; added-to-channel activity; channel members/settings; agent library/team overlaps; agent creation; mention autocomplete; and DM header/sidebar/settings ### UI evidence The complete labeled visual matrix is available in the originating Buzz review thread. GitHub-hosted copies will be added in a follow-up PR comment using the repository screenshot script. --------- Signed-off-by: Fizz <dae5f6af70b8695a8b83c8deae555f63be41630ec2b8cd493e41a439c9527dd8@buzz.block.builderlab.xyz> Co-authored-by: Fizz <dae5f6af70b8695a8b83c8deae555f63be41630ec2b8cd493e41a439c9527dd8@buzz.block.builderlab.xyz>
214 lines
6.1 KiB
Dart
214 lines
6.1 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
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 '../community/community_provider.dart';
|
|
import '../emoji/emoji_avatar.dart';
|
|
import '../emoji/native_emoji_glyph.dart';
|
|
import '../push/push_presentation_cache.dart';
|
|
import '../relay/relay.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,
|
|
),
|
|
);
|
|
if (!isAgent) {
|
|
return CircleAvatar(
|
|
radius: radius,
|
|
backgroundColor: color,
|
|
child: ClipOval(child: content),
|
|
);
|
|
}
|
|
|
|
final borderRadius = BorderRadius.circular(radius * 0.6);
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(color: color, borderRadius: borderRadius),
|
|
child: ClipRRect(borderRadius: borderRadius, child: content),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
String? _scheduledPushAvatar;
|
|
|
|
@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);
|
|
final communityID = ref.watch(activeCommunityProvider).value?.id;
|
|
|
|
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(
|
|
communityID: communityID,
|
|
sourceURL: widget.imageUrl,
|
|
bytes: bytes,
|
|
fallback: centeredFallback,
|
|
),
|
|
_NetworkAvatarSource(:final url) => MediaImage(
|
|
url: url,
|
|
fit: widget.fit,
|
|
onBytesLoaded: communityID == null
|
|
? null
|
|
: (bytes) => unawaited(
|
|
cacheBuzzPushAvatarFromLoadedBytes(communityID, url, bytes),
|
|
),
|
|
errorBuilder: (_, _, _) => centeredFallback,
|
|
),
|
|
null => centeredFallback,
|
|
};
|
|
}
|
|
|
|
Widget _rasterImage({
|
|
required String? communityID,
|
|
required String? sourceURL,
|
|
required Uint8List bytes,
|
|
required Widget fallback,
|
|
}) {
|
|
if (communityID != null && sourceURL != null) {
|
|
final identity = '$communityID\u0000$sourceURL';
|
|
if (_scheduledPushAvatar != identity) {
|
|
_scheduledPushAvatar = identity;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || _scheduledPushAvatar != identity) return;
|
|
unawaited(
|
|
cacheBuzzPushAvatarFromLoadedBytes(communityID, sourceURL, bytes),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
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);
|
|
}
|