Files
buzz/mobile/lib/features/channels/reply_summary_link.dart
klopez4212 9296660d1e Match mobile reply links to desktop connectors (#8362)
## Summary
Reply summaries now appear below mobile message bubbles as inset
avatar/count links with the same rounded connector used on desktop.
Reaction trays keep their overlap with the bubble; connector clearance
adjusts when reactions are present. Channel and nested reply summaries
share the same 20px avatar stack, capped at three, with a 44px minimum
tap target and accessible thread-opening label.

### Related issue
Follows merged #8359 and now targets `main`. This diff contains only the
reply-link changes. No matching duplicate was found in the open issue/PR
search for reply connectors.

### Testing
- Full mobile formatting, analysis, and tests passed before stacking:
3,348 tests plus 3 push-configuration tests, 4 skips.
- Normal pre-push checks passed on the rebased head against main:
formatting, analysis, full mobile tests, and repository file-size
checks.
- Before stacking, installed and launched the preview on Pixel 10 and
iPhone Air with existing app data. Ten profile/community/draft
preference values remained unchanged on each. Verified both connector
spacing variants and thread opening on Pixel; the owner tried and
approved the preview. iPhone screenshot verification was unavailable.
- The rebased build has not been reinstalled on devices. Device evidence
above is from the approved preview before rebasing.

Before: channel replies were filled pills beside reactions; nested
replies used a separate larger avatar row. After, as verified in the
device preview:

![Pixel preview showing reply connectors with and without
reactions](https://buzz.block.builderlab.xyz/media/119f0fbd358895ef3fe5e6c1bc4f2c399c660b1debded5efff08864ff796792c.png)

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-15 13:08:41 -04:00

150 lines
5.0 KiB
Dart

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/theme/theme.dart';
import 'small_avatar.dart';
import 'timeline_message.dart';
/// Desktop-style reply link with a curved branch below the message bubble.
class ReplySummaryLink extends ConsumerWidget {
const ReplySummaryLink({
super.key,
required this.summary,
required this.onTap,
this.hasReactions = false,
});
final ThreadSummary summary;
final VoidCallback onTap;
final bool hasReactions;
@override
Widget build(BuildContext context, WidgetRef ref) {
final participants = summary.participantPubkeys.take(3).toList();
final userCache = ref.watch(userCacheProvider);
final label = summary.isCountPending
? 'Replies'
: '${summary.replyCount}${summary.isLowerBound ? '+' : ''} ${summary.replyCount == 1 ? 'reply' : 'replies'}';
final connectorTop = hasReactions ? -Grid.half : Grid.half;
return Padding(
padding: const EdgeInsetsDirectional.only(start: Grid.xxs),
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
top: connectorTop,
child: IgnorePointer(
child: CustomPaint(
painter: _ReplyConnector(
context.colors.outlineVariant,
Directionality.of(context),
connectorTop,
),
),
),
),
Padding(
padding: const EdgeInsetsDirectional.only(start: Grid.twelve),
child: Semantics(
button: true,
label: 'View thread: $label',
onTap: onTap,
excludeSemantics: true,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(Radii.container),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 44),
child: Padding(
padding: const EdgeInsets.all(Grid.half),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (participants.isNotEmpty) ...[
SizedBox(
width:
Grid.gutter +
(participants.length - 1) * Grid.twelve,
height: Grid.gutter,
child: Stack(
children: [
for (var i = 0; i < participants.length; i++)
PositionedDirectional(
start: i * Grid.twelve,
child: SmallAvatar(
pubkey: participants[i],
userCache: userCache,
),
),
],
),
),
const SizedBox(width: Grid.half),
],
Flexible(
child: Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
),
),
),
),
),
),
],
),
);
}
}
class _ReplyConnector extends CustomPainter {
const _ReplyConnector(this.color, this.direction, this.topOffset);
final Color color;
final TextDirection direction;
final double topOffset;
@override
void paint(Canvas canvas, Size size) {
canvas.save();
if (direction == TextDirection.rtl) {
canvas.translate(size.width, 0);
canvas.scale(-1, 1);
}
// Keep the endpoint aligned with the unchanged reply avatars.
final middle = (size.height - topOffset) / 2;
// Desktop uses a 12px outer corner with a 2px border: the stroke's
// centerline follows an 11px circular arc, rather than a tight elbow.
const radius = Grid.twelve - 1;
final path = Path()
..moveTo(1, 0)
..lineTo(1, middle - radius)
..arcToPoint(
Offset(Grid.twelve, middle),
radius: const Radius.circular(radius),
clockwise: false,
);
canvas.drawPath(
path,
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 2,
);
canvas.restore();
}
@override
bool shouldRepaint(_ReplyConnector oldDelegate) =>
color != oldDelegate.color ||
direction != oldDelegate.direction ||
topOffset != oldDelegate.topOffset;
}