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

@@ -0,0 +1,80 @@
// Version: 1.0.0 | Created: 2026-07-04
// Deterministic test for the chat timeline's scroll-to-latest button.
// Playwright cannot drive scrolling on the Flutter CanvasKit surface, so the
// jump-to-latest behaviour is verified here against the real widget tree.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:matrix/matrix.dart';
import 'package:m8chat_app/core/network/matrix_client.dart';
import 'package:m8chat_app/features/chat/domain/message_model.dart';
import 'package:m8chat_app/features/chat/presentation/chat_controller.dart';
import 'package:m8chat_app/features/chat/presentation/chat_screen.dart';
void main() {
testWidgets(
'jump-to-latest button appears when scrolled up and returns to bottom',
(tester) async {
const roomId = '!scrolltest:server';
final base = DateTime(2026, 7, 4, 8);
// Index 0 is the newest message (the timeline stream is newest-first).
final messages = [
for (var i = 40; i >= 1; i--)
MessageModel(
eventId: '\$e$i',
roomId: roomId,
senderId: '@me:server',
senderDisplayName: 'Me',
timestamp: base.add(Duration(seconds: i)),
type: MessageType.text,
body: 'Message $i',
isMine: true,
),
];
await tester.pumpWidget(
ProviderScope(
overrides: [
matrixClientProvider.overrideWithValue(Client('test-client')),
chatTimelineProvider(
roomId,
).overrideWith((ref) => Stream.value(messages)),
],
child: const MaterialApp(home: ChatScreen(roomId: roomId)),
),
);
await tester.pumpAndSettle();
final listView = find.byType(ListView);
expect(listView, findsOneWidget);
double offset() =>
tester.state<ScrollableState>(find.byType(Scrollable).first)
.position
.pixels;
// Starts pinned to the newest message (offset 0 in a reverse list).
expect(offset(), 0);
// Scroll up toward older messages (reverse list: drag content down).
await tester.drag(listView, const Offset(0, 900));
await tester.pumpAndSettle();
expect(
offset(),
greaterThan(400),
reason: 'should have scrolled well past the button threshold',
);
// The jump-to-latest button is now shown; tap it.
final jumpButton = find.byIcon(Icons.arrow_downward);
expect(jumpButton, findsOneWidget);
await tester.tap(jumpButton);
await tester.pumpAndSettle();
// Back at the newest message.
expect(offset(), 0);
},
);
}