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

@@ -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),
],
),
),
);
},
),
),
),
);
}