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:
@@ -114,3 +114,12 @@
|
|||||||
- **Verified in headless Chromium on production build:** grouping renders (Sam's 3-run collapsed to tight blue bubbles; Alex's 2-run shows name once + avatar on last bubble only, amber deterministic disc), "Today" chip renders, quick-react row renders with all six presets + picker button, tapping ❤️ sent a real m.reaction (confirmed via server /messages). Long-press context menu still fires (all of Reply/React/Copy/Edit/Delete intact).
|
- **Verified in headless Chromium on production build:** grouping renders (Sam's 3-run collapsed to tight blue bubbles; Alex's 2-run shows name once + avatar on last bubble only, amber deterministic disc), "Today" chip renders, quick-react row renders with all six presets + picker button, tapping ❤️ sent a real m.reaction (confirmed via server /messages). Long-press context menu still fires (all of Reply/React/Copy/Edit/Delete intact).
|
||||||
- **Note:** cross-day separators are logic-only verified (can't backdate events without an appservice); the same-day "Today" chip confirms the render path.
|
- **Note:** cross-day separators are logic-only verified (can't backdate events without an appservice); the same-day "Today" chip confirms the render path.
|
||||||
- **Status:** built + deployed app2.m8chat.au v1.9.0+13 (backup pre190 tarball). Analyze clean, format clean, tests pass.
|
- **Status:** built + deployed app2.m8chat.au v1.9.0+13 (backup pre190 tarball). Analyze clean, format clean, tests pass.
|
||||||
|
|
||||||
|
### 2026-07-04 08:30 — Scroll-to-latest + panic hold-ring (v1.10.0+14)
|
||||||
|
- **B6 scroll-to-latest:** _Timeline converted ConsumerWidget → ConsumerStatefulWidget with a ScrollController. When offset > 400 (reverse list, newest at 0), an AnimatedScale FloatingActionButton.small(arrow_downward) fades in at Positioned(right:12,bottom:12) inside a Stack around the ListView (NOT the Scaffold FAB slot). Tap → animateTo(0, 200ms, easeOut).
|
||||||
|
- **B5 panic hold-ring:** panic_button.dart → onLongPressDown starts a 700ms AnimationController; a red CircularProgressIndicator(value: controller.value) fills around the emergency_share icon; on completion HapticFeedback.heavyImpact + fire; early release resets silently. onTap still shows the hint; configured guard + _sending state preserved.
|
||||||
|
- **Verified:**
|
||||||
|
- Panic ring: headless Chromium screenshot mid-hold shows the red arc ~half-filled; completing the hold fired the alert (🚨 text landed in the SES Emergency room; location correctly skipped when geolocation not granted, proving the alert-before-location safeguard).
|
||||||
|
- Scroll-to-latest: the Flutter CanvasKit surface would NOT accept synthetic scroll input from Playwright (mouse wheel and drag both no-op on the canvas; input field steals focus). Verified instead with a deterministic widget test (test/scroll_to_latest_test.dart): 40 messages, starts at offset 0, drag up → offset > 400 + button appears, tap → offset back to 0. Passes.
|
||||||
|
- **Note for future:** getTimeline() only yields the ~10-event sync window and there is no scroll-back history pagination, so on a normal phone the timeline rarely exceeds ~1 screen and the jump button seldom shows in practice. Wiring requestHistory() on near-top scroll would make both the button and long-scrollback genuinely useful. Flagged, not built.
|
||||||
|
- **Status:** built + deployed app2.m8chat.au v1.10.0+14 (backup pre1100 tarball). Analyze clean, 4 tests pass.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ abstract final class AppConfig {
|
|||||||
'turns:matrix.m8chat.au:5349',
|
'turns:matrix.m8chat.au:5349',
|
||||||
];
|
];
|
||||||
static const String appName = 'M8Chat';
|
static const String appName = 'M8Chat';
|
||||||
static const String appVersion = '1.9.0';
|
static const String appVersion = '1.10.0';
|
||||||
|
|
||||||
// Jitsi conferencing
|
// Jitsi conferencing
|
||||||
static const String jitsiDomain = 'conf.m8chat.au';
|
static const String jitsiDomain = 'conf.m8chat.au';
|
||||||
|
|||||||
@@ -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
|
// Full chat screen — timeline + input + typing indicators + read receipts
|
||||||
// + long-press context menu (quick reactions, reply, react, copy, edit,
|
// + long-press context menu (quick reactions, reply, react, copy, edit,
|
||||||
// delete) + day separators + same-sender message grouping + room options.
|
// delete) + day separators + same-sender message grouping + room options.
|
||||||
@@ -128,15 +128,57 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
// Timeline
|
// Timeline
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class _Timeline extends ConsumerWidget {
|
class _Timeline extends ConsumerStatefulWidget {
|
||||||
const _Timeline({required this.roomId, required this.onReply});
|
const _Timeline({required this.roomId, required this.onReply});
|
||||||
|
|
||||||
final String roomId;
|
final String roomId;
|
||||||
final void Function(MessageModel) onReply;
|
final void Function(MessageModel) onReply;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<_Timeline> createState() => _TimelineState();
|
||||||
final timelineAsync = ref.watch(chatTimelineProvider(roomId));
|
}
|
||||||
|
|
||||||
|
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(
|
return timelineAsync.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
@@ -158,7 +200,10 @@ class _Timeline extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return Stack(
|
||||||
|
children: [
|
||||||
|
ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
reverse: true,
|
reverse: true,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
itemCount: messages.length,
|
itemCount: messages.length,
|
||||||
@@ -188,8 +233,8 @@ class _Timeline extends ConsumerWidget {
|
|||||||
|
|
||||||
final bubble = _MessageWithGestures(
|
final bubble = _MessageWithGestures(
|
||||||
message: message,
|
message: message,
|
||||||
roomId: roomId,
|
roomId: widget.roomId,
|
||||||
onReply: () => onReply(message),
|
onReply: () => widget.onReply(message),
|
||||||
isFirstInGroup: isFirstInGroup,
|
isFirstInGroup: isFirstInGroup,
|
||||||
isLastInGroup: isLastInGroup,
|
isLastInGroup: isLastInGroup,
|
||||||
);
|
);
|
||||||
@@ -203,6 +248,22 @@ class _Timeline extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// Version: 1.0.0 | Created: 2026-07-04
|
// Version: 1.1.0 | Created: 2026-07-04 | Updated: 2026-07-04
|
||||||
// Panic trigger button for the rooms AppBar. Push-to-talk style:
|
// Panic trigger button for the rooms AppBar. Push-to-talk style: HOLD for
|
||||||
// HOLD to fire the alert; a plain tap only shows the hint. Only rendered
|
// 700ms to fire the alert, with a filling ring showing progress so a brush
|
||||||
// when a panic configuration exists in account data.
|
// cannot fire it and the user sees exactly when it sends. A plain tap only
|
||||||
|
// shows the hint. Only rendered when a panic configuration exists.
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../data/panic_repository.dart';
|
import '../data/panic_repository.dart';
|
||||||
@@ -15,9 +17,47 @@ class PanicButton extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<PanicButton> createState() => _PanicButtonState();
|
ConsumerState<PanicButton> createState() => _PanicButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PanicButtonState extends ConsumerState<PanicButton> {
|
class _PanicButtonState extends ConsumerState<PanicButton>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
static const _holdDuration = Duration(milliseconds: 700);
|
||||||
|
|
||||||
|
late final AnimationController _holdController;
|
||||||
bool _sending = false;
|
bool _sending = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_holdController = AnimationController(vsync: this, duration: _holdDuration)
|
||||||
|
..addStatusListener(_onHoldStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_holdController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onHoldStatus(AnimationStatus status) {
|
||||||
|
// The ring filled all the way: the hold completed, fire the alert.
|
||||||
|
if (status == AnimationStatus.completed) {
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
|
_trigger();
|
||||||
|
_holdController.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onHoldStart() {
|
||||||
|
if (_sending) return;
|
||||||
|
_holdController.forward(from: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onHoldCancelled() {
|
||||||
|
// Released before the ring filled: cancel silently.
|
||||||
|
if (_holdController.status == AnimationStatus.forward) {
|
||||||
|
_holdController.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _trigger() async {
|
Future<void> _trigger() async {
|
||||||
if (_sending) return;
|
if (_sending) return;
|
||||||
setState(() => _sending = true);
|
setState(() => _sending = true);
|
||||||
@@ -32,9 +72,15 @@ class _PanicButtonState extends ConsumerState<PanicButton> {
|
|||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
backgroundColor: error == null ? Colors.green.shade800 : null,
|
backgroundColor: error == null ? Colors.green.shade800 : null,
|
||||||
content: Text(
|
content: Text(error ?? 'Emergency alert sent with your location.'),
|
||||||
error ?? 'Emergency alert sent with your location.',
|
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showHint() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Hold the button to send the emergency alert.'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -47,24 +93,55 @@ class _PanicButtonState extends ConsumerState<PanicButton> {
|
|||||||
if (!configured) return const SizedBox.shrink();
|
if (!configured) return const SizedBox.shrink();
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onLongPress: _trigger,
|
onTap: _showHint,
|
||||||
child: IconButton(
|
onLongPressDown: (_) => _onHoldStart(),
|
||||||
tooltip: 'Hold to send emergency alert',
|
onLongPressUp: _onHoldCancelled,
|
||||||
icon: _sending
|
onLongPressCancel: _onHoldCancelled,
|
||||||
? const SizedBox(
|
child: Tooltip(
|
||||||
width: 20,
|
message: 'Hold to send emergency alert',
|
||||||
height: 20,
|
child: SizedBox(
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
width: 48,
|
||||||
)
|
height: 48,
|
||||||
: const Icon(Icons.emergency_share, color: Colors.red),
|
child: Center(
|
||||||
onPressed: () {
|
child: SizedBox(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
width: 40,
|
||||||
const SnackBar(
|
height: 40,
|
||||||
content: Text('Hold the button to send the emergency alert.'),
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
// Progress ring, only painted while the hold is in progress.
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: _holdController,
|
||||||
|
builder: (context, _) {
|
||||||
|
if (_holdController.value == 0) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return SizedBox(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: _holdController.value,
|
||||||
|
strokeWidth: 3,
|
||||||
|
color: Colors.red,
|
||||||
|
backgroundColor: Colors.red.withAlpha(40),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (_sending)
|
||||||
|
const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const Icon(Icons.emergency_share, color: Colors.red),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: m8chat_app
|
name: m8chat_app
|
||||||
description: "M8Chat — Matrix chat client for Android, iOS, and Web."
|
description: "M8Chat — Matrix chat client for Android, iOS, and Web."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.9.0+13
|
version: 1.10.0+14
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.11.0 <4.0.0'
|
sdk: '>=3.11.0 <4.0.0'
|
||||||
|
|||||||
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