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>
148 lines
4.6 KiB
Dart
148 lines
4.6 KiB
Dart
// 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';
|
|
|
|
class PanicButton extends ConsumerStatefulWidget {
|
|
const PanicButton({super.key});
|
|
|
|
@override
|
|
ConsumerState<PanicButton> createState() => _PanicButtonState();
|
|
}
|
|
|
|
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);
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
messenger.showSnackBar(
|
|
const SnackBar(content: Text('Sending emergency alert…')),
|
|
);
|
|
final error = await ref.read(panicRepositoryProvider).trigger();
|
|
if (!mounted) return;
|
|
setState(() => _sending = false);
|
|
messenger.hideCurrentSnackBar();
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
backgroundColor: error == null ? Colors.green.shade800 : null,
|
|
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.'),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Hide when not configured. Config lives in account data, which is
|
|
// available once sync has run; rebuilds happen with the parent screen.
|
|
final configured = ref.watch(panicRepositoryProvider).config != null;
|
|
if (!configured) return const SizedBox.shrink();
|
|
|
|
return GestureDetector(
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|