diff --git a/help4bis-claude-notes/decisions.md b/help4bis-claude-notes/decisions.md index 3a4d62a..169308b 100644 --- a/help4bis-claude-notes/decisions.md +++ b/help4bis-claude-notes/decisions.md @@ -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). - **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. + +### 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. diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 973211b..b49835a 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -11,7 +11,7 @@ abstract final class AppConfig { 'turns:matrix.m8chat.au:5349', ]; static const String appName = 'M8Chat'; - static const String appVersion = '1.9.0'; + static const String appVersion = '1.10.0'; // Jitsi conferencing static const String jitsiDomain = 'conf.m8chat.au'; diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index d4d8caa..4bc4999 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -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 { // 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), + ), + ), + ), + ], ); }, ); diff --git a/lib/features/panic/presentation/panic_button.dart b/lib/features/panic/presentation/panic_button.dart index 933ca17..0ce5dc0 100644 --- a/lib/features/panic/presentation/panic_button.dart +++ b/lib/features/panic/presentation/panic_button.dart @@ -1,9 +1,11 @@ -// Version: 1.0.0 | Created: 2026-07-04 -// Panic trigger button for the rooms AppBar. Push-to-talk style: -// HOLD to fire the alert; a plain tap only shows the hint. Only rendered -// when a panic configuration exists in account data. +// Version: 1.1.0 | Created: 2026-07-04 | Updated: 2026-07-04 +// Panic trigger button for the rooms AppBar. Push-to-talk style: HOLD for +// 700ms to fire the alert, with a filling ring showing progress so a brush +// 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/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/panic_repository.dart'; @@ -15,9 +17,47 @@ class PanicButton extends ConsumerStatefulWidget { ConsumerState createState() => _PanicButtonState(); } -class _PanicButtonState extends ConsumerState { +class _PanicButtonState extends ConsumerState + with SingleTickerProviderStateMixin { + static const _holdDuration = Duration(milliseconds: 700); + + late final AnimationController _holdController; 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 _trigger() async { if (_sending) return; setState(() => _sending = true); @@ -32,9 +72,15 @@ class _PanicButtonState extends ConsumerState { messenger.showSnackBar( SnackBar( backgroundColor: error == null ? Colors.green.shade800 : null, - content: Text( - error ?? 'Emergency alert sent with your location.', - ), + content: Text(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,23 +93,54 @@ class _PanicButtonState extends ConsumerState { if (!configured) return const SizedBox.shrink(); return GestureDetector( - onLongPress: _trigger, - child: IconButton( - tooltip: 'Hold to send emergency alert', - icon: _sending - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.emergency_share, color: Colors.red), - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Hold the button to send the emergency alert.'), + onTap: _showHint, + onLongPressDown: (_) => _onHoldStart(), + onLongPressUp: _onHoldCancelled, + onLongPressCancel: _onHoldCancelled, + child: Tooltip( + message: 'Hold to send emergency alert', + child: SizedBox( + width: 48, + height: 48, + child: Center( + child: SizedBox( + width: 40, + height: 40, + 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), + ], + ), ), - ); - }, + ), + ), ), ); } diff --git a/pubspec.yaml b/pubspec.yaml index f9ca2db..87bd4fe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: m8chat_app description: "M8Chat — Matrix chat client for Android, iOS, and Web." publish_to: 'none' -version: 1.9.0+13 +version: 1.10.0+14 environment: sdk: '>=3.11.0 <4.0.0' diff --git a/test/scroll_to_latest_test.dart b/test/scroll_to_latest_test.dart new file mode 100644 index 0000000..e74a97f --- /dev/null +++ b/test/scroll_to_latest_test.dart @@ -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(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); + }, + ); +}