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:
80
test/scroll_to_latest_test.dart
Normal file
80
test/scroll_to_latest_test.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user