Files
b30f1f6129 Polish mobile profiles, DMs, and sheets (#5401)
## Summary
- add poster-first, tap-to-toggle animated avatars on profile surfaces
while preserving transparent/static behavior elsewhere
- align mobile DM headers, membership actions, and invisible agent
recipient addressing with established desktop semantics
- polish titled sheets and status editing, preserve native iOS sheet
corners, and batch relay reads to improve review-build responsiveness

## Snapshots

<table>
  <tr>
    <th>Profile avatar</th>
    <th>Agent DM header and composer</th>
  </tr>
  <tr>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--profile-avatar.png"
width="360" alt="Mobile profile settings with animated avatar
surface"></td>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--agent-dm.png"
width="360" alt="Agent direct message with masked presence and normal
composer"></td>
  </tr>
  <tr>
    <th>Members sheet</th>
    <th>Status editor</th>
  </tr>
  <tr>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--members-sheet.png"
width="360" alt="Members bottom sheet with centered title and padded
content"></td>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--status-sheet.png"
width="360" alt="Status editor bottom sheet with duration and quick
statuses"></td>
  </tr>
  <tr>
    <th colspan="2">Switch Community</th>
  </tr>
  <tr>
<td colspan="2" align="center"><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--switch-community.png"
width="720" alt="Switch Community bottom sheet with centered title and
aligned Edit action"></td>
  </tr>
</table>

## Validation
- `just mobile-check`
- `just mobile-test` (1,283 tests)
- installed and reviewed isolated debug builds on iPhone and Pixel

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-13 20:16:04 -07:00

186 lines
5.3 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../animated_avatar.dart';
import '../relay/relay.dart';
/// A circular 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;
const AvatarImage({
super.key,
required this.imageUrl,
required this.radius,
required this.fallback,
this.backgroundColor,
});
@override
Widget build(BuildContext context) {
final animatedAvatar = parseAnimatedAvatarUrl(imageUrl);
return CircleAvatar(
radius: radius,
// Animated avatar posters carry their own backdrop disc; preserve their
// transparent surroundings on static/list surfaces, matching desktop.
backgroundColor: animatedAvatar == null
? backgroundColor
: Colors.transparent,
child: ClipOval(
child: SizedBox.square(
dimension: radius * 2,
child: AvatarImageContent(
imageUrl: animatedAvatar?.posterUrl ?? imageUrl,
fallback: fallback,
),
),
),
);
}
}
/// Image content for avatar surfaces whose shape is supplied by their parent.
class AvatarImageContent extends StatefulWidget {
final String? imageUrl;
final Widget fallback;
final BoxFit fit;
const AvatarImageContent({
super.key,
required this.imageUrl,
required this.fallback,
this.fit = BoxFit.cover,
});
@override
State<AvatarImageContent> createState() => _AvatarImageContentState();
}
class _AvatarImageContentState extends State<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) => Center(
child: Text(
emoji,
textScaler: TextScaler.noScaling,
style: TextStyle(
fontSize: constraints.biggest.shortestSide * 258 / 512,
height: 1,
),
),
),
),
),
_SvgAvatarSource(:final svg) => SvgPicture.string(
svg,
fit: widget.fit,
placeholderBuilder: (_) => centeredFallback,
errorBuilder: (_, _, _) => centeredFallback,
),
_RasterDataAvatarSource(:final bytes) => Image.memory(
bytes,
fit: widget.fit,
errorBuilder: (_, _, _) => centeredFallback,
),
_NetworkAvatarSource(:final url) => MediaImage(
url: url,
fit: widget.fit,
errorBuilder: (_, _, _) => centeredFallback,
),
null => centeredFallback,
};
}
}
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);
return _parseEmojiAvatar(svg) ?? _SvgAvatarSource(svg);
}
return _RasterDataAvatarSource(data.contentAsBytes());
} on FormatException {
return null;
}
}
}
_EmojiAvatarSource? _parseEmojiAvatar(String svg) {
final colorValue = RegExp(
r'<rect\b[^>]*\sfill="([^"]+)"',
).firstMatch(svg)?[1];
final emojiValue = RegExp(r'<text\b[^>]*>(.*?)</text>').firstMatch(svg)?[1];
if (colorValue == null || emojiValue == null) return null;
final color = _parseHexColor(colorValue);
if (color == null) return null;
final emoji = emojiValue
.replaceAll('&gt;', '>')
.replaceAll('&lt;', '<')
.replaceAll('&amp;', '&');
return _EmojiAvatarSource(emoji, color);
}
Color? _parseHexColor(String value) {
final hex = value.startsWith('#') ? value.substring(1) : value;
if (!RegExp(r'^[0-9a-fA-F]{6}$').hasMatch(hex)) return null;
final rgb = int.tryParse(hex, radix: 16);
return rgb == null ? null : Color(0xFF000000 | rgb);
}
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);
}