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:
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
@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 {
|
||||
if (_sending) return;
|
||||
setState(() => _sending = true);
|
||||
@@ -32,9 +72,15 @@ class _PanicButtonState extends ConsumerState<PanicButton> {
|
||||
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<PanicButton> {
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user