// 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 createState() => _PanicButtonState(); } 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); 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), ], ), ), ), ), ), ); } }