mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
## Summary Refresh mobile conversations with left-aligned grouped bubbles without tails, tighter consecutive-message spacing, distinct sender colors, theme-aware incoming backgrounds, and compact overlapping reaction trays. Attach reply counts beside reactions with up to three participant avatars; keep outgoing mentions and links readable in both themes. Coordinate a 500 ms composer-to-message transition with spring contraction and an anchored avatar. Message ownership follows the signing identity so profile reloads cannot recolor outgoing bubbles. Center channel activities without timestamps or icons, keep names tappable, and show two affected members plus a **See all** sheet. Hide legacy purpose activities, match media corners to bubble groups, and add iOS status-bar scroll-to-top. This is an independent mobile PR; no desktop changes or color picker. ## Validation - Full `just mobile-check` passed; `just mobile-test` passed 3,275 tests with 4 skips, plus 3 unconfigured-gateway checks. - Fixed message-widget lint warnings and a stale notification fixture that blocked analysis. Made the video-controller disposal test wait for lifecycle events instead of fixed delays. Mobile pre-push checks are enabled. - Physical iPhone previews were installed throughout development. The latest bubble, reply-avatar, contrast, and spacing updates were installed and launched on the physical iPhone. Final motion and status-bar behavior still need device visual verification; Android has automated coverage but no physical-device verification. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
149 lines
4.2 KiB
Dart
149 lines
4.2 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
|
|
import 'local_message_send_transition.dart';
|
|
|
|
/// Paints a local-send bubble from the captured composer rectangle.
|
|
/// The text travels with the bubble while keeping its final size and wrapping.
|
|
class LocalSendBubbleSurface extends SingleChildRenderObjectWidget {
|
|
/// Creates an animated background without changing the child layout.
|
|
const LocalSendBubbleSurface({
|
|
super.key,
|
|
required this.color,
|
|
required this.shape,
|
|
required super.child,
|
|
});
|
|
|
|
/// Bubble fill in the active theme.
|
|
final Color color;
|
|
|
|
/// Final conversation geometry, including grouped corners and thought dots.
|
|
final ShapeBorder shape;
|
|
|
|
@override
|
|
RenderObject createRenderObject(BuildContext context) =>
|
|
_RenderSendBubble(color, shape, LocalSendProgress.scopeOf(context));
|
|
|
|
@override
|
|
void updateRenderObject(
|
|
BuildContext context,
|
|
covariant RenderProxyBox renderObject,
|
|
) {
|
|
(renderObject as _RenderSendBubble).update(
|
|
color,
|
|
shape,
|
|
LocalSendProgress.scopeOf(context),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RenderSendBubble extends RenderProxyBox {
|
|
_RenderSendBubble(this.color, this.shape, this.motion);
|
|
|
|
Color color;
|
|
ShapeBorder shape;
|
|
LocalSendProgress? motion;
|
|
double get progress => motion?.progress ?? 1;
|
|
|
|
void update(
|
|
Color nextColor,
|
|
ShapeBorder nextShape,
|
|
LocalSendProgress? nextMotion,
|
|
) {
|
|
if (color == nextColor && shape == nextShape && motion == nextMotion) {
|
|
return;
|
|
}
|
|
color = nextColor;
|
|
shape = nextShape;
|
|
motion = nextMotion;
|
|
markNeedsPaint();
|
|
markNeedsSemanticsUpdate();
|
|
}
|
|
|
|
@override
|
|
Rect get paintBounds {
|
|
final width = constraints.hasBoundedWidth
|
|
? constraints.maxWidth
|
|
: size.width;
|
|
final bounds = shape
|
|
.getOuterPath(Offset.zero & Size(width, size.height))
|
|
.getBounds()
|
|
.expandToInclude(super.paintBounds);
|
|
return bounds
|
|
.expandToInclude(animatedRect)
|
|
.expandToInclude(child?.paintBounds.shift(childOffset) ?? Rect.zero);
|
|
}
|
|
|
|
Rect get animatedRect {
|
|
final end = Offset.zero & size;
|
|
final origin = motion?.origin;
|
|
final start = origin == null
|
|
? Rect.fromLTWH(
|
|
0,
|
|
motion?.startOffset ?? 0,
|
|
constraints.hasBoundedWidth ? constraints.maxWidth : size.width,
|
|
size.height,
|
|
)
|
|
: origin.shift(-localToGlobal(Offset.zero));
|
|
// Contract horizontally first, then finish lifting into the timeline.
|
|
// Both tracks share a clock, but their overlapping curves differ.
|
|
final horizontal = Rect.lerp(
|
|
start,
|
|
end,
|
|
motion?.widthProgress ?? progress,
|
|
)!;
|
|
final vertical = Rect.lerp(start, end, progress)!;
|
|
return Rect.fromLTWH(
|
|
horizontal.left,
|
|
vertical.top,
|
|
horizontal.width,
|
|
vertical.height,
|
|
);
|
|
}
|
|
|
|
Offset get childOffset => progress < 1 ? animatedRect.topLeft : Offset.zero;
|
|
|
|
@override
|
|
void applyPaintTransform(RenderBox child, Matrix4 transform) {
|
|
transform.translateByDouble(childOffset.dx, childOffset.dy, 0, 1);
|
|
}
|
|
|
|
@override
|
|
bool hitTest(BoxHitTestResult result, {required Offset position}) {
|
|
final rect = animatedRect;
|
|
if (!shape.getOuterPath(rect).contains(position)) return false;
|
|
if (!hitTestChildren(result, position: position)) return false;
|
|
result.add(BoxHitTestEntry(this, position));
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) =>
|
|
result.addWithPaintOffset(
|
|
offset: childOffset,
|
|
position: position,
|
|
hitTest: (result, position) =>
|
|
child?.hitTest(result, position: position) ?? false,
|
|
);
|
|
|
|
@override
|
|
void paint(PaintingContext context, Offset offset) {
|
|
final rect = animatedRect;
|
|
final path = shape.getOuterPath(rect.shift(offset));
|
|
context.canvas.drawPath(path, Paint()..color = color);
|
|
if (child case final child?) {
|
|
if (progress < 1) {
|
|
context.pushClipPath(
|
|
needsCompositing,
|
|
Offset.zero,
|
|
path.getBounds(),
|
|
path,
|
|
(context, _) => context.paintChild(child, offset + rect.topLeft),
|
|
);
|
|
} else {
|
|
context.paintChild(child, offset);
|
|
}
|
|
}
|
|
}
|
|
}
|