Files
buzz/mobile/lib/features/channels/reply_summary_link.dart
klopez4212 27b9c827fc Polish mobile message layout and navigation controls (#8427)
Align outgoing messages and galleries to the right, reclaim the outgoing
avatar gutter, and match desktop calendar dividers with balanced
spacing. Standardize navigation and sheet icons at 24 points, restore
native Edit/Done styling, and align section menus with trailing channel
indicators.

Validation: clean analysis; 3,513 mobile tests and push-configuration
checks passed; iPhone Release build installed and tested by the owner.
Includes regression coverage for message placement, date spacing,
gallery direction, and section alignment.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: fin <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-18 15:17:25 -04:00

160 lines
5.3 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,
this.mirrored = false,
});
final ThreadSummary summary;
final VoidCallback onTap;
final bool hasReactions;
/// Places the connector on the outgoing edge without reversing its contents.
final bool mirrored;
@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: EdgeInsets.only(
left: mirrored ? 0 : Grid.xxs,
right: mirrored ? Grid.xxs : 0,
),
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
top: connectorTop,
child: IgnorePointer(
child: CustomPaint(
painter: _ReplyConnector(
context.colors.outlineVariant,
mirrored ? TextDirection.rtl : TextDirection.ltr,
connectorTop,
),
),
),
),
Padding(
padding: EdgeInsets.only(
left: mirrored ? 0 : Grid.twelve,
right: mirrored ? Grid.twelve : 0,
),
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;
}