feat(chat): scroll-to-latest button + panic hold-progress ring (v1.10.0+14)

B6 scroll-to-latest: ScrollController on the timeline; a small
  arrow-down FAB fades in once scrolled >400px up and animates back
  to the newest message on tap (overlaid on the list, not the FAB slot)
B5 panic hold-ring: 700ms AnimationController drives a red progress
  ring around the panic icon; haptic + fire on completion, silent
  reset on early release; tap-hint and configured guard preserved

Panic ring verified in-browser (ring fills, alert fires to the room).
Scroll-to-latest verified with a deterministic widget test because the
CanvasKit surface rejects synthetic scroll input in headless Chromium.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 08:32:48 +10:00
parent f55f8dc698
commit f9c21fddc4
6 changed files with 298 additions and 71 deletions

View File

@@ -1,4 +1,4 @@
// Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-07-04
// Version: 1.6.0 | Created: 2026-04-01 | Updated: 2026-07-04
// Full chat screen — timeline + input + typing indicators + read receipts
// + long-press context menu (quick reactions, reply, react, copy, edit,
// delete) + day separators + same-sender message grouping + room options.
@@ -128,15 +128,57 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
// Timeline
// ---------------------------------------------------------------------------
class _Timeline extends ConsumerWidget {
class _Timeline extends ConsumerStatefulWidget {
const _Timeline({required this.roomId, required this.onReply});
final String roomId;
final void Function(MessageModel) onReply;
@override
Widget build(BuildContext context, WidgetRef ref) {
final timelineAsync = ref.watch(chatTimelineProvider(roomId));
ConsumerState<_Timeline> createState() => _TimelineState();
}
class _TimelineState extends ConsumerState<_Timeline> {
final _scrollController = ScrollController();
/// The list is reverse:true, so the newest message sits at offset 0.
/// Show the jump-to-latest button once the user scrolls up past this.
static const _showButtonThreshold = 400.0;
bool _showScrollToLatest = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (!_scrollController.hasClients) return;
final show = _scrollController.offset > _showButtonThreshold;
if (show != _showScrollToLatest) {
setState(() => _showScrollToLatest = show);
}
}
void _scrollToLatest() {
if (!_scrollController.hasClients) return;
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
@override
Widget build(BuildContext context) {
final timelineAsync = ref.watch(chatTimelineProvider(widget.roomId));
return timelineAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
@@ -158,51 +200,70 @@ class _Timeline extends ConsumerWidget {
);
}
return ListView.builder(
reverse: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
// List is reverse:true, so the visually-older neighbour is at
// index+1 and the visually-newer neighbour is at index-1.
final older = index < messages.length - 1
? messages[index + 1]
: null;
final newer = index > 0 ? messages[index - 1] : null;
return Stack(
children: [
ListView.builder(
controller: _scrollController,
reverse: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
// List is reverse:true, so the visually-older neighbour is at
// index+1 and the visually-newer neighbour is at index-1.
final older = index < messages.length - 1
? messages[index + 1]
: null;
final newer = index > 0 ? messages[index - 1] : null;
const groupGap = Duration(minutes: 5);
final isFirstInGroup =
older == null ||
older.senderId != message.senderId ||
message.timestamp.difference(older.timestamp) > groupGap;
final isLastInGroup =
newer == null ||
newer.senderId != message.senderId ||
newer.timestamp.difference(message.timestamp) > groupGap;
const groupGap = Duration(minutes: 5);
final isFirstInGroup =
older == null ||
older.senderId != message.senderId ||
message.timestamp.difference(older.timestamp) > groupGap;
final isLastInGroup =
newer == null ||
newer.senderId != message.senderId ||
newer.timestamp.difference(message.timestamp) > groupGap;
// A day chip sits above the first message of each local day.
final showDay =
older == null ||
!_sameLocalDay(message.timestamp, older.timestamp);
// A day chip sits above the first message of each local day.
final showDay =
older == null ||
!_sameLocalDay(message.timestamp, older.timestamp);
final bubble = _MessageWithGestures(
message: message,
roomId: roomId,
onReply: () => onReply(message),
isFirstInGroup: isFirstInGroup,
isLastInGroup: isLastInGroup,
);
final bubble = _MessageWithGestures(
message: message,
roomId: widget.roomId,
onReply: () => widget.onReply(message),
isFirstInGroup: isFirstInGroup,
isLastInGroup: isLastInGroup,
);
if (!showDay) return bubble;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_DayChip(timestamp: message.timestamp),
bubble,
],
);
},
if (!showDay) return bubble;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_DayChip(timestamp: message.timestamp),
bubble,
],
);
},
),
Positioned(
right: 12,
bottom: 12,
child: AnimatedScale(
duration: const Duration(milliseconds: 150),
scale: _showScrollToLatest ? 1 : 0,
child: FloatingActionButton.small(
heroTag: 'scrollToLatest',
onPressed: _scrollToLatest,
tooltip: 'Jump to latest',
child: const Icon(Icons.arrow_downward),
),
),
),
],
);
},
);